From bab2e3082ff7d88817749d4d155fdbcbe63b77e8 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Wed, 26 Mar 2025 13:11:07 +0100 Subject: [PATCH 01/15] feat: implement nlp based blocks prioritization strategy feat: add weight to nlp entity schema and readapt feat: remove commented obsolete code feat: restore settings feat: apply feedback fix: re-adapt unit tests feat: priority scoring re-calculation & enabling weight modification in builtin nlp entities fix: remove obsolete code feat: refine unit tests, apply mr coderabbit suggestions fix: minor refactoring feat: add nlp cache map type feat: refine builtin nlp entities weight updates feat: add more test cases and refine edge case handling feat: add weight validation in UI fix: apply feedback feat: add a penalty factor & fix unit tests feat: add documentation fix: correct syntax fix: remove stale log statement fix: enforce nlp entity weight restrictions fix: correct typo in docs fix: typos in docs fix: fix formatting for function comment fix: restore matchNLP function previous code fix: remove blank line, make updateOne asynchronous fix: add AND operator in docs fix: handle dependency injection in chat module feat: refactor to use findAndPopulate in block score calculation feat: refine caching mechanisms feat: add typing and enforce safety checks fix: remove typo fix: remove async from block score calculation fix: remove typo fix: correct linting fix: refine nlp pattern type check fix: decompose code into helper utils, add nlp entity dto validation, remove type casting fix: minor refactoring feat: refactor current implementation --- api/docs/nlp/README.md | 102 +++++++++ api/src/chat/chat.module.ts | 2 + .../chat/controllers/block.controller.spec.ts | 18 ++ api/src/chat/schemas/types/pattern.ts | 18 ++ api/src/chat/services/block.service.spec.ts | 194 ++++++++++++++++-- api/src/chat/services/block.service.ts | 164 +++++++++++++-- api/src/chat/services/bot.service.spec.ts | 16 ++ .../lib/__test__/base-nlp-helper.spec.ts | 3 + .../translation.controller.spec.ts | 16 ++ .../controllers/nlp-entity.controller.spec.ts | 64 +++++- .../nlp/controllers/nlp-entity.controller.ts | 15 +- .../controllers/nlp-sample.controller.spec.ts | 1 + .../controllers/nlp-value.controller.spec.ts | 7 + api/src/nlp/dto/nlp-entity.dto.ts | 16 +- api/src/nlp/nlp.module.ts | 2 +- api/src/nlp/schemas/nlp-entity.schema.ts | 8 +- api/src/nlp/schemas/types.ts | 10 +- .../nlp/services/nlp-entity.service.spec.ts | 127 +++++++++++- api/src/nlp/services/nlp-entity.service.ts | 79 ++++++- .../nlp-sample-entity.service.spec.ts | 7 + .../nlp/services/nlp-value.service.spec.ts | 7 + api/src/utils/constants/cache.ts | 2 + api/src/utils/test/fixtures/nlpentity.ts | 5 +- api/src/utils/test/mocks/block.ts | 119 ++++++++++- api/src/utils/test/mocks/nlp.ts | 52 ++++- frontend/public/locales/en/translation.json | 5 +- frontend/public/locales/fr/translation.json | 5 +- .../tables/columns/getColumns.tsx | 3 +- .../components/nlp/components/NlpEntity.tsx | 10 + .../nlp/components/NlpEntityForm.tsx | 30 +++ frontend/src/types/nlp-entity.types.ts | 3 +- 31 files changed, 1061 insertions(+), 49 deletions(-) create mode 100644 api/docs/nlp/README.md diff --git a/api/docs/nlp/README.md b/api/docs/nlp/README.md new file mode 100644 index 00000000..cc7daa2e --- /dev/null +++ b/api/docs/nlp/README.md @@ -0,0 +1,102 @@ +# NLP Block Scoring +## Purpose + +**NLP Block Scoring** is a mechanism used to select the most relevant response block based on: + +- Matching patterns between user input and block definitions +- Configurable weights assigned to each entity type +- Confidence values provided by the NLU engine for detected entities + +It enables more intelligent and context-aware block selection in conversational flows. + +## Core Use Cases +### Standard Matching + +A user input contains entities that directly match a block’s patterns. +```ts +Example: Input: intent = enquiry & subject = claim +Block A: Patterns: intent: enquiry & subject: claim +Block A will be selected. +``` + +### High Confidence, Partial Match + +A block may match only some patterns but have high-confidence input on those matched ones, making it a better candidate than others with full matches but low-confidence entities. +**Note: Confidence is multiplied by a pre-defined weight for each entity type.** + +```ts +Example: +Input: intent = issue (confidence: 0.92) & subject = claim (confidence: 0.65) +Block A: Pattern: intent: issue +Block B: Pattern: subject: claim +➤ Block A gets a high score based on confidence × weight (assuming both weights are equal to 1). +``` + +### Multiple Blocks with Similar Patterns + +```ts +Input: intent = issue & subject = insurance +Block A: intent = enquiry & subject = insurance +Block B: subject = insurance +➤ Block B is selected — Block A mismatches on intent. +``` + +### Exclusion Due to Extra Patterns + +If a block contains patterns that require entities not present in the user input, the block is excluded from scoring altogether. No penalties are applied — the block simply isn't considered a valid candidate. + +```ts +Input: intent = issue & subject = insurance +Block A: intent = enquiry & subject = insurance & location = office +Block B: subject = insurance & time = morning +➤ Neither block is selected due to unmatched required patterns (`location`, `time`) +``` + +### Tie-Breaking with Penalty Factors + +When multiple blocks receive similar scores, penalty factors can help break the tie — especially in cases where patterns are less specific (e.g., using `Any` as a value). + +```ts +Input: intent = enquiry & subject = insurance + +Block A: intent = enquiry & subject = Any +Block B: intent = enquiry & subject = insurance +Block C: subject = insurance + +Scoring Summary: +- Block A matches both patterns, but subject = Any is considered less specific. +- Block B has a redundant but fully specific match. +- Block C matches only one pattern. + +➤ Block A and Block B have similar raw scores. +➤ A penalty factor is applied to Block A due to its use of Any, reducing its final score. +➤ Block B is selected. +``` + +## How Scoring Works +### Matching and Confidence + +For each entity in the block's pattern: +- If the entity `matches` an entity in the user input: + - the score is increased by: `confidence × weight` + - `Confidence` is a value between 0 and 1, returned by the NLU engine. + - `Weight` (default value is `1`) is a configured importance factor for that specific entity type. +- If the match is a wildcard (i.e., the block accepts any value): + - A **penalty factor** is applied to slightly reduce its contribution: + ``confidence × weight × penaltyFactor``. This encourages more specific matches when available. + +### Scoring Formula Summary + +For each matched entity: + +```ts +score += confidence × weight × [optional penalty factor if wildcard] +``` + +The total block score is the sum of all matched patterns in that block. + +### Penalty Factor + +The **penalty factor** is a global multiplier (typically less than `1`, e.g., `0.8`) applied when the match type is less specific — such as wildcard or loose entity type matches. It allows the system to: +- Break ties in favor of more precise blocks +- Discourage overly generic blocks from being selected when better matches are available diff --git a/api/src/chat/chat.module.ts b/api/src/chat/chat.module.ts index 1452d296..48bfcab5 100644 --- a/api/src/chat/chat.module.ts +++ b/api/src/chat/chat.module.ts @@ -16,6 +16,7 @@ import { AttachmentModel } from '@/attachment/schemas/attachment.schema'; import { AttachmentService } from '@/attachment/services/attachment.service'; import { ChannelModule } from '@/channel/channel.module'; import { CmsModule } from '@/cms/cms.module'; +import { NlpModule } from '@/nlp/nlp.module'; import { UserModule } from '@/user/user.module'; import { BlockController } from './controllers/block.controller'; @@ -68,6 +69,7 @@ import { SubscriberService } from './services/subscriber.service'; AttachmentModule, EventEmitter2, UserModule, + NlpModule, ], controllers: [ CategoryController, diff --git a/api/src/chat/controllers/block.controller.spec.ts b/api/src/chat/controllers/block.controller.spec.ts index 24945c3d..426a19b3 100644 --- a/api/src/chat/controllers/block.controller.spec.ts +++ b/api/src/chat/controllers/block.controller.spec.ts @@ -20,6 +20,15 @@ import { LanguageRepository } from '@/i18n/repositories/language.repository'; import { LanguageModel } from '@/i18n/schemas/language.schema'; import { I18nService } from '@/i18n/services/i18n.service'; import { LanguageService } from '@/i18n/services/language.service'; +import { LoggerService } from '@/logger/logger.service'; +import { NlpEntityRepository } from '@/nlp/repositories/nlp-entity.repository'; +import { NlpSampleEntityRepository } from '@/nlp/repositories/nlp-sample-entity.repository'; +import { NlpValueRepository } from '@/nlp/repositories/nlp-value.repository'; +import { NlpEntityModel } from '@/nlp/schemas/nlp-entity.schema'; +import { NlpSampleEntityModel } from '@/nlp/schemas/nlp-sample-entity.schema'; +import { NlpValueModel } from '@/nlp/schemas/nlp-value.schema'; +import { NlpEntityService } from '@/nlp/services/nlp-entity.service'; +import { NlpValueService } from '@/nlp/services/nlp-value.service'; import { PluginService } from '@/plugins/plugins.service'; import { SettingService } from '@/setting/services/setting.service'; import { InvitationRepository } from '@/user/repositories/invitation.repository'; @@ -93,6 +102,9 @@ describe('BlockController', () => { RoleModel, PermissionModel, LanguageModel, + NlpEntityModel, + NlpSampleEntityModel, + NlpValueModel, ]), ], providers: [ @@ -116,6 +128,12 @@ describe('BlockController', () => { PermissionService, LanguageService, PluginService, + LoggerService, + NlpEntityService, + NlpEntityRepository, + NlpSampleEntityRepository, + NlpValueRepository, + NlpValueService, { provide: I18nService, useValue: { diff --git a/api/src/chat/schemas/types/pattern.ts b/api/src/chat/schemas/types/pattern.ts index 48df5efe..6b430f69 100644 --- a/api/src/chat/schemas/types/pattern.ts +++ b/api/src/chat/schemas/types/pattern.ts @@ -8,6 +8,8 @@ import { z } from 'zod'; +import { BlockFull } from '../block.schema'; + import { PayloadType } from './button'; export const payloadPatternSchema = z.object({ @@ -57,3 +59,19 @@ export const patternSchema = z.union([ ]); export type Pattern = z.infer; + +export type NlpPatternMatchResult = { + block: BlockFull; + matchedPattern: NlpPattern[]; +}; + +export function isNlpPattern(pattern: NlpPattern) { + return ( + (typeof pattern === 'object' && + pattern !== null && + 'entity' in pattern && + 'match' in pattern && + pattern.match === 'entity') || + pattern.match === 'value' + ); +} diff --git a/api/src/chat/services/block.service.spec.ts b/api/src/chat/services/block.service.spec.ts index 393d9160..6fb810f3 100644 --- a/api/src/chat/services/block.service.spec.ts +++ b/api/src/chat/services/block.service.spec.ts @@ -31,6 +31,14 @@ import { LanguageRepository } from '@/i18n/repositories/language.repository'; import { LanguageModel } from '@/i18n/schemas/language.schema'; import { I18nService } from '@/i18n/services/i18n.service'; import { LanguageService } from '@/i18n/services/language.service'; +import { NlpEntityRepository } from '@/nlp/repositories/nlp-entity.repository'; +import { NlpSampleEntityRepository } from '@/nlp/repositories/nlp-sample-entity.repository'; +import { NlpValueRepository } from '@/nlp/repositories/nlp-value.repository'; +import { NlpEntityModel } from '@/nlp/schemas/nlp-entity.schema'; +import { NlpSampleEntityModel } from '@/nlp/schemas/nlp-sample-entity.schema'; +import { NlpValueModel } from '@/nlp/schemas/nlp-value.schema'; +import { NlpEntityService } from '@/nlp/services/nlp-entity.service'; +import { NlpValueService } from '@/nlp/services/nlp-value.service'; import { PluginService } from '@/plugins/plugins.service'; import { SettingService } from '@/setting/services/setting.service'; import { @@ -43,12 +51,23 @@ import { blockGetStarted, blockProductListMock, blocks, + mockModifiedNlpBlock, + mockModifiedNlpBlockOne, + mockModifiedNlpBlockTwo, + mockNlpBlock, + mockNlpPatternsSetOne, + mockNlpPatternsSetThree, + mockNlpPatternsSetTwo, } from '@/utils/test/mocks/block'; import { contextBlankInstance, subscriberContextBlankInstance, } from '@/utils/test/mocks/conversation'; -import { nlpEntitiesGreeting } from '@/utils/test/mocks/nlp'; +import { + mockNlpCacheMap, + mockNlpEntitiesSetOne, + nlpEntitiesGreeting, +} from '@/utils/test/mocks/nlp'; import { closeInMongodConnection, rootMongooseTestModule, @@ -56,7 +75,7 @@ import { import { buildTestingMocks } from '@/utils/test/utils'; import { BlockRepository } from '../repositories/block.repository'; -import { Block, BlockModel } from '../schemas/block.schema'; +import { Block, BlockFull, BlockModel } from '../schemas/block.schema'; import { Category, CategoryModel } from '../schemas/category.schema'; import { LabelModel } from '../schemas/label.schema'; import { FileType } from '../schemas/types/attachment'; @@ -75,6 +94,7 @@ describe('BlockService', () => { let hasPreviousBlocks: Block; let contentService: ContentService; let contentTypeService: ContentTypeService; + let nlpEntityService: NlpEntityService; beforeAll(async () => { const { getMocks } = await buildTestingMocks({ @@ -91,6 +111,9 @@ describe('BlockService', () => { AttachmentModel, LabelModel, LanguageModel, + NlpEntityModel, + NlpSampleEntityModel, + NlpValueModel, ]), ], providers: [ @@ -106,6 +129,14 @@ describe('BlockService', () => { ContentService, AttachmentService, LanguageService, + NlpEntityRepository, + NlpValueRepository, + NlpSampleEntityRepository, + NlpEntityService, + { + provide: NlpValueService, + useValue: {}, + }, { provide: PluginService, useValue: {}, @@ -145,12 +176,14 @@ describe('BlockService', () => { contentTypeService, categoryRepository, blockRepository, + nlpEntityService, ] = await getMocks([ BlockService, ContentService, ContentTypeService, CategoryRepository, BlockRepository, + NlpEntityService, ]); category = (await categoryRepository.findOne({ label: 'default' }))!; hasPreviousBlocks = (await blockRepository.findOne({ @@ -291,32 +324,163 @@ describe('BlockService', () => { blockGetStarted, ); expect(result).toEqual([ - { - entity: 'intent', - match: 'value', - value: 'greeting', - }, - { - entity: 'firstname', - match: 'entity', - }, + [ + { + entity: 'intent', + match: 'value', + value: 'greeting', + }, + { + entity: 'firstname', + match: 'entity', + }, + ], ]); }); - it('should return undefined when it does not match nlp patterns', () => { + it('should return empty array when it does not match nlp patterns', () => { const result = blockService.matchNLP(nlpEntitiesGreeting, { ...blockGetStarted, patterns: [[{ entity: 'lastname', match: 'value', value: 'Belakhel' }]], }); - expect(result).toEqual(undefined); + expect(result).toEqual([]); }); - it('should return undefined when unknown nlp patterns', () => { + it('should return empty array when unknown nlp patterns', () => { const result = blockService.matchNLP(nlpEntitiesGreeting, { ...blockGetStarted, patterns: [[{ entity: 'product', match: 'value', value: 'pizza' }]], }); - expect(result).toEqual(undefined); + expect(result).toEqual([]); + }); + }); + + describe('matchBestNLP', () => { + it('should return the block with the highest NLP score', async () => { + jest + .spyOn(nlpEntityService, 'getNlpMap') + .mockResolvedValue(mockNlpCacheMap); + const blocks = [mockNlpBlock, blockGetStarted]; // You can add more blocks with different patterns and scores + const nlp = mockNlpEntitiesSetOne; + // Spy on calculateBlockScore to check if it's called + const calculateBlockScoreSpy = jest.spyOn( + blockService, + 'calculateBlockScore', + ); + const bestBlock = await blockService.matchBestNLP(blocks, nlp); + + // Ensure calculateBlockScore was called at least once for each block + expect(calculateBlockScoreSpy).toHaveBeenCalledTimes(2); // Called for each block + + // Restore the spy after the test + calculateBlockScoreSpy.mockRestore(); + // Assert that the block with the highest NLP score is selected + expect(bestBlock).toEqual(mockNlpBlock); + }); + + it('should return the block with the highest NLP score applying penalties', async () => { + jest + .spyOn(nlpEntityService, 'getNlpMap') + .mockResolvedValue(mockNlpCacheMap); + const blocks = [mockNlpBlock, mockModifiedNlpBlock]; // You can add more blocks with different patterns and scores + const nlp = mockNlpEntitiesSetOne; + // Spy on calculateBlockScore to check if it's called + const calculateBlockScoreSpy = jest.spyOn( + blockService, + 'calculateBlockScore', + ); + const bestBlock = await blockService.matchBestNLP(blocks, nlp); + + // Ensure calculateBlockScore was called at least once for each block + expect(calculateBlockScoreSpy).toHaveBeenCalledTimes(2); // Called for each block + + // Restore the spy after the test + calculateBlockScoreSpy.mockRestore(); + // Assert that the block with the highest NLP score is selected + expect(bestBlock).toEqual(mockNlpBlock); + }); + + it('another case where it should return the block with the highest NLP score applying penalties', async () => { + jest + .spyOn(nlpEntityService, 'getNlpMap') + .mockResolvedValue(mockNlpCacheMap); + const blocks = [mockModifiedNlpBlockOne, mockModifiedNlpBlockTwo]; // You can add more blocks with different patterns and scores + const nlp = mockNlpEntitiesSetOne; + // Spy on calculateBlockScore to check if it's called + const calculateBlockScoreSpy = jest.spyOn( + blockService, + 'calculateBlockScore', + ); + const bestBlock = await blockService.matchBestNLP(blocks, nlp); + + // Ensure calculateBlockScore was called at least once for each block + expect(calculateBlockScoreSpy).toHaveBeenCalledTimes(3); // Called for each block + + // Restore the spy after the test + calculateBlockScoreSpy.mockRestore(); + // Assert that the block with the highest NLP score is selected + expect(bestBlock).toEqual(mockModifiedNlpBlockTwo); + }); + + it('should return undefined if no blocks match or the list is empty', async () => { + jest + .spyOn(nlpEntityService, 'getNlpMap') + .mockResolvedValue(mockNlpCacheMap); + const blocks: BlockFull[] = []; // Empty block array + const nlp = mockNlpEntitiesSetOne; + + const bestBlock = await blockService.matchBestNLP(blocks, nlp); + + // Assert that undefined is returned when no blocks are available + expect(bestBlock).toBeUndefined(); + }); + }); + + describe('calculateBlockScore', () => { + it('should calculate the correct NLP score for a block', async () => { + jest + .spyOn(nlpEntityService, 'getNlpMap') + .mockResolvedValue(mockNlpCacheMap); + const score = await blockService.calculateBlockScore( + mockNlpPatternsSetOne, + mockNlpEntitiesSetOne, + ); + const score2 = await blockService.calculateBlockScore( + mockNlpPatternsSetTwo, + mockNlpEntitiesSetOne, + ); + + expect(score).toBeGreaterThan(0); + expect(score2).toBe(0); + expect(score).toBeGreaterThan(score2); + }); + + it('should calculate the correct NLP score for a block and apply penalties ', async () => { + jest + .spyOn(nlpEntityService, 'getNlpMap') + .mockResolvedValue(mockNlpCacheMap); + const score = await blockService.calculateBlockScore( + mockNlpPatternsSetOne, + mockNlpEntitiesSetOne, + ); + const score2 = await blockService.calculateBlockScore( + mockNlpPatternsSetThree, + mockNlpEntitiesSetOne, + ); + + expect(score).toBeGreaterThan(0); + expect(score2).toBeGreaterThan(0); + expect(score).toBeGreaterThan(score2); + }); + + it('should return 0 if no matching entities are found', async () => { + jest.spyOn(nlpEntityService, 'getNlpMap').mockResolvedValue(new Map()); + const score = await blockService.calculateBlockScore( + mockNlpPatternsSetTwo, + mockNlpEntitiesSetOne, + ); + + expect(score).toBe(0); // No matching entity, so score should be 0 }); }); diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index 5f0dbbb8..65d5ef2b 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -16,6 +16,8 @@ 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'; +import { NlpCacheMapValues } from '@/nlp/schemas/types'; +import { NlpEntityService } from '@/nlp/services/nlp-entity.service'; import { PluginService } from '@/plugins/plugins.service'; import { PluginType } from '@/plugins/types'; import { SettingService } from '@/setting/services/setting.service'; @@ -53,6 +55,7 @@ export class BlockService extends BaseService< private readonly pluginService: PluginService, protected readonly i18n: I18nService, protected readonly languageService: LanguageService, + protected readonly entityService: NlpEntityService, ) { super(repository); } @@ -181,20 +184,21 @@ export class BlockService extends BaseService< .shift(); // Perform an NLP Match + if (!block && nlp) { - // Find block pattern having the best match of nlp entities - let nlpBest = 0; - filteredBlocks.forEach((b, index, self) => { - const nlpPattern = this.matchNLP(nlp, b); - if (nlpPattern && nlpPattern.length > nlpBest) { - nlpBest = nlpPattern.length; - block = self[index]; - } - }); + // Use the `reduce` function to iterate over `filteredBlocks` and accumulate a new array `matchesWithPatterns`. + // This approach combines the matching of NLP patterns and filtering of blocks with empty or invalid matches + // into a single operation. This avoids the need for a separate mapping and filtering step, improving performance. + // For each block in `filteredBlocks`, we call `matchNLP` to find patterns that match the NLP data. + // If `matchNLP` returns a non-empty list of matched patterns, the block and its matched patterns are added + // to the accumulator array `acc`, which is returned as the final result. + // This ensures that only blocks with valid matches are kept, and blocks with no matches are excluded, + // all while iterating through the list only once. + + block = await this.matchBestNLP(filteredBlocks, nlp); } } - // Uknown event type => return false; - // this.logger.error('Unable to recognize event type while matching', event); + return block; } @@ -304,7 +308,7 @@ export class BlockService extends BaseService< matchNLP( nlp: NLU.ParseEntities, block: Block | BlockFull, - ): NlpPattern[] | undefined { + ): NlpPattern[][] | undefined { // No nlp entities to check against if (nlp.entities.length === 0) { return undefined; @@ -313,14 +317,13 @@ export class BlockService extends BaseService< const nlpPatterns = block.patterns?.filter((p) => { return Array.isArray(p); }) as NlpPattern[][]; - // No nlp patterns found if (nlpPatterns.length === 0) { return undefined; } // Find NLP pattern match based on best guessed entities - return nlpPatterns.find((entities: NlpPattern[]) => { + return nlpPatterns.filter((entities: NlpPattern[]) => { return entities.every((ev: NlpPattern) => { if (ev.match === 'value') { return nlp.entities.find((e) => { @@ -338,6 +341,139 @@ export class BlockService extends BaseService< }); } + /** + * Matches the provided NLU parsed entities with patterns in a set of blocks and returns + * the block with the highest matching score. + * + * For each block, it checks the patterns against the NLU parsed entities, calculates + * a score for each match, and selects the block with the highest score. + * + * @param {BlockFull[]} blocks - An array of BlockFull objects representing potential matches. + * @param {NLU.ParseEntities} nlp - The NLU parsed entities used for pattern matching. + * + * @returns {Promise} - A promise that resolves to the BlockFull + * with the highest match score, or undefined if no matches are found. + */ + async matchBestNLP( + blocks: BlockFull[], + nlp: NLU.ParseEntities, + ): Promise { + const scoredBlocks = await Promise.all( + blocks.map(async (block) => { + const matchedPatterns = this.matchNLP(nlp, block) || []; + + const scores = await Promise.all( + matchedPatterns.map((pattern) => + this.calculateBlockScore(pattern, nlp), + ), + ); + + const maxScore = scores.length > 0 ? Math.max(...scores) : 0; + + return { block, score: maxScore }; + }), + ); + + const best = scoredBlocks.reduce( + (acc, curr) => (curr.score > acc.score ? curr : acc), + { block: undefined, score: 0 }, + ); + + return best.block; + } + + /** + * Computes the NLP score for a given block using its matched NLP patterns and parsed NLP entities. + * + * Each pattern is evaluated against the parsed NLP entities to determine matches based on entity name, + * value, and confidence. A score is computed using the entity's weight and the confidence level of the match. + * A penalty factor is optionally applied for entity-level matches to adjust the scoring. + * + * The function uses a cache (`nlpCacheMap`) to avoid redundant database lookups for entity metadata. + * + * @param patterns - The NLP patterns associated with the block. + * @param nlp - The parsed NLP entities from the user input. + * @param nlpCacheMap - A cache to reuse fetched entity metadata (e.g., weights and valid values). + * @param nlpPenaltyFactor - A multiplier applied to scores when the pattern match type is 'entity'. + * @returns A numeric score representing how well the block matches the given NLP context. + */ + async calculateBlockScore( + patterns: NlpPattern[], + nlp: NLU.ParseEntities, + ): Promise { + if (!patterns.length) return 0; + + const nlpCacheMap = await this.entityService.getNlpMap(); + // @TODO Make nluPenaltyFactor configurable in UI settings + const nluPenaltyFactor = 0.95; + // Compute individual pattern scores using the cache + const patternScores: number[] = patterns.map((pattern) => { + const entityData = nlpCacheMap.get(pattern.entity); + if (!entityData) return 0; + + const matchedEntity: NLU.ParseEntity | undefined = nlp.entities.find( + (e) => this.matchesEntityData(e, pattern, entityData), + ); + + return this.computePatternScore( + matchedEntity, + pattern, + entityData, + nluPenaltyFactor, + ); + }); + + // Sum the scores + return patternScores.reduce((sum, score) => sum + score, 0); + } + + /** + * Checks if a given `ParseEntity` from the NLP model matches the specified pattern + * and if its value exists within the values provided in the cache for the specified entity. + * + * @param e - The `ParseEntity` object from the NLP model, containing information about the entity and its value. + * @param pattern - The `NlpPattern` object representing the entity and value pattern to be matched. + * @param entityData - The `NlpCacheMapValues` object containing cached data, including entity values and weight, for the entity being matched. + * + * @returns A boolean indicating whether the `ParseEntity` matches the pattern and entity data from the cache. + * + * - The function compares the entity type between the `ParseEntity` and the `NlpPattern`. + * - If the pattern's match type is not `'value'`, it checks if the entity's value is present in the cache's `values` array. + * - If the pattern's match type is `'value'`, it further ensures that the entity's value matches the specified value in the pattern. + * - Returns `true` if all conditions are met, otherwise `false`. + */ + private matchesEntityData( + e: NLU.ParseEntity, + pattern: NlpPattern, + entityData: NlpCacheMapValues, + ): boolean { + return ( + e.entity === pattern.entity && + entityData?.values.some((v) => v === e.value) && + (pattern.match !== 'value' || e.value === pattern.value) + ); + } + + /** + * Computes the score for a given entity based on its confidence, weight, and penalty factor. + * + * @param entity - The `ParseEntity` to check, which may be `undefined` if no match is found. + * @param pattern - The `NlpPattern` object that specifies how to match the entity and its value. + * @param entityData - The cached data for the given entity, including `weight` and `values`. + * @param nlpPenaltyFactor - The penalty factor applied when the pattern's match type is 'entity'. + * @returns The computed score based on the entity's confidence, the cached weight, and the penalty factor. + */ + private computePatternScore( + entity: NLU.ParseEntity | undefined, + pattern: NlpPattern, + entityData: NlpCacheMapValues, + nlpPenaltyFactor: number, + ): number { + if (!entity || !entity.confidence) return 0; + const penalty = pattern.match === 'entity' ? nlpPenaltyFactor : 1; + return entity.confidence * entityData.weight * penalty; + } + /** * Matches an outcome-based block from a list of available blocks * based on the outcome of a system message. diff --git a/api/src/chat/services/bot.service.spec.ts b/api/src/chat/services/bot.service.spec.ts index 794f6420..ed574e52 100644 --- a/api/src/chat/services/bot.service.spec.ts +++ b/api/src/chat/services/bot.service.spec.ts @@ -33,6 +33,14 @@ import { LanguageRepository } from '@/i18n/repositories/language.repository'; import { LanguageModel } from '@/i18n/schemas/language.schema'; import { I18nService } from '@/i18n/services/i18n.service'; import { LanguageService } from '@/i18n/services/language.service'; +import { NlpEntityRepository } from '@/nlp/repositories/nlp-entity.repository'; +import { NlpSampleEntityRepository } from '@/nlp/repositories/nlp-sample-entity.repository'; +import { NlpValueRepository } from '@/nlp/repositories/nlp-value.repository'; +import { NlpEntityModel } from '@/nlp/schemas/nlp-entity.schema'; +import { NlpSampleEntityModel } from '@/nlp/schemas/nlp-sample-entity.schema'; +import { NlpValueModel } from '@/nlp/schemas/nlp-value.schema'; +import { NlpEntityService } from '@/nlp/services/nlp-entity.service'; +import { NlpValueService } from '@/nlp/services/nlp-value.service'; import { PluginService } from '@/plugins/plugins.service'; import { SettingService } from '@/setting/services/setting.service'; import { installBlockFixtures } from '@/utils/test/fixtures/block'; @@ -100,6 +108,9 @@ describe('BlockService', () => { MenuModel, ContextVarModel, LanguageModel, + NlpEntityModel, + NlpSampleEntityModel, + NlpValueModel, ]), JwtModule, ], @@ -131,6 +142,11 @@ describe('BlockService', () => { ContextVarService, ContextVarRepository, LanguageService, + NlpEntityService, + NlpEntityRepository, + NlpSampleEntityRepository, + NlpValueRepository, + NlpValueService, { provide: HelperService, useValue: {}, diff --git a/api/src/helper/lib/__test__/base-nlp-helper.spec.ts b/api/src/helper/lib/__test__/base-nlp-helper.spec.ts index 4f2f8ddb..c9509b1d 100644 --- a/api/src/helper/lib/__test__/base-nlp-helper.spec.ts +++ b/api/src/helper/lib/__test__/base-nlp-helper.spec.ts @@ -139,6 +139,7 @@ describe('BaseNlpHelper', () => { updatedAt: new Date(), builtin: false, lookups: [], + weight: 1, }, entity2: { id: new ObjectId().toString(), @@ -147,6 +148,7 @@ describe('BaseNlpHelper', () => { updatedAt: new Date(), builtin: false, lookups: [], + weight: 1, }, }); jest.spyOn(NlpValue, 'getValueMap').mockReturnValue({ @@ -207,6 +209,7 @@ describe('BaseNlpHelper', () => { updatedAt: new Date(), builtin: false, lookups: [], + weight: 1, }, }); diff --git a/api/src/i18n/controllers/translation.controller.spec.ts b/api/src/i18n/controllers/translation.controller.spec.ts index 06dabf28..e6b4d3ce 100644 --- a/api/src/i18n/controllers/translation.controller.spec.ts +++ b/api/src/i18n/controllers/translation.controller.spec.ts @@ -30,6 +30,14 @@ import { MenuModel } from '@/cms/schemas/menu.schema'; import { ContentService } from '@/cms/services/content.service'; import { MenuService } from '@/cms/services/menu.service'; import { I18nService } from '@/i18n/services/i18n.service'; +import { NlpEntityRepository } from '@/nlp/repositories/nlp-entity.repository'; +import { NlpSampleEntityRepository } from '@/nlp/repositories/nlp-sample-entity.repository'; +import { NlpValueRepository } from '@/nlp/repositories/nlp-value.repository'; +import { NlpEntityModel } from '@/nlp/schemas/nlp-entity.schema'; +import { NlpSampleEntityModel } from '@/nlp/schemas/nlp-sample-entity.schema'; +import { NlpValueModel } from '@/nlp/schemas/nlp-value.schema'; +import { NlpEntityService } from '@/nlp/services/nlp-entity.service'; +import { NlpValueService } from '@/nlp/services/nlp-value.service'; import { NlpService } from '@/nlp/services/nlp.service'; import { PluginService } from '@/plugins/plugins.service'; import { SettingService } from '@/setting/services/setting.service'; @@ -75,6 +83,9 @@ describe('TranslationController', () => { BlockModel, ContentModel, LanguageModel, + NlpEntityModel, + NlpSampleEntityModel, + NlpValueModel, ]), ], providers: [ @@ -130,6 +141,11 @@ describe('TranslationController', () => { }, LanguageService, LanguageRepository, + NlpEntityRepository, + NlpEntityService, + NlpValueRepository, + NlpValueService, + NlpSampleEntityRepository, ], }); [translationService, translationController] = await getMocks([ diff --git a/api/src/nlp/controllers/nlp-entity.controller.spec.ts b/api/src/nlp/controllers/nlp-entity.controller.spec.ts index c1334ef9..8eadf357 100644 --- a/api/src/nlp/controllers/nlp-entity.controller.spec.ts +++ b/api/src/nlp/controllers/nlp-entity.controller.spec.ts @@ -6,6 +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 { CACHE_MANAGER } from '@nestjs/cache-manager'; import { BadRequestException, MethodNotAllowedException, @@ -67,6 +68,12 @@ describe('NlpEntityController', () => { NlpValueService, NlpSampleEntityRepository, NlpValueRepository, + { + provide: CACHE_MANAGER, + useValue: { + del: jest.fn(), + }, + }, ], }); [nlpEntityController, nlpValueService, nlpEntityService] = await getMocks([ @@ -109,6 +116,7 @@ describe('NlpEntityController', () => { ) as NlpEntityFull['values'], lookups: curr.lookups!, builtin: curr.builtin!, + weight: curr.weight!, }); return acc; }, @@ -163,6 +171,7 @@ describe('NlpEntityController', () => { name: 'sentiment', lookups: ['trait'], builtin: false, + weight: 1, }; const result = await nlpEntityController.create(sentimentEntity); expect(result).toEqualPayload(sentimentEntity); @@ -214,6 +223,7 @@ describe('NlpEntityController', () => { updatedAt: firstNameEntity!.updatedAt, lookups: firstNameEntity!.lookups, builtin: firstNameEntity!.builtin, + weight: firstNameEntity!.weight, }; const result = await nlpEntityController.findOne(firstNameEntity!.id, [ 'values', @@ -238,6 +248,7 @@ describe('NlpEntityController', () => { doc: '', lookups: ['trait'], builtin: false, + weight: 1, }; const result = await nlpEntityController.updateOne( firstNameEntity!.id, @@ -258,7 +269,7 @@ describe('NlpEntityController', () => { ).rejects.toThrow(NotFoundException); }); - it('should throw exception when nlp entity is builtin', async () => { + it('should throw an exception if entity is builtin but weight not provided', async () => { const updateNlpEntity: NlpEntityCreateDto = { name: 'updated', doc: '', @@ -269,6 +280,57 @@ describe('NlpEntityController', () => { nlpEntityController.updateOne(buitInEntityId!, updateNlpEntity), ).rejects.toThrow(MethodNotAllowedException); }); + + it('should update weight if entity is builtin and weight is provided', async () => { + const updatedNlpEntity: NlpEntityCreateDto = { + name: 'updated', + doc: '', + lookups: ['trait'], + builtin: false, + weight: 4, + }; + const findOneSpy = jest.spyOn(nlpEntityService, 'findOne'); + const updateWeightSpy = jest.spyOn(nlpEntityService, 'updateWeight'); + + const result = await nlpEntityController.updateOne( + buitInEntityId!, + updatedNlpEntity, + ); + + expect(findOneSpy).toHaveBeenCalledWith(buitInEntityId!); + expect(updateWeightSpy).toHaveBeenCalledWith( + buitInEntityId!, + updatedNlpEntity.weight, + ); + expect(result.weight).toBe(updatedNlpEntity.weight); + }); + + it('should update only the weight of the builtin entity', async () => { + const updatedNlpEntity: NlpEntityCreateDto = { + name: 'updated', + doc: '', + lookups: ['trait'], + builtin: false, + weight: 4, + }; + const originalEntity: NlpEntity | null = await nlpEntityService.findOne( + buitInEntityId!, + ); + + const result: NlpEntity = await nlpEntityController.updateOne( + buitInEntityId!, + updatedNlpEntity, + ); + + // Check weight is updated + expect(result.weight).toBe(updatedNlpEntity.weight); + + Object.entries(originalEntity!).forEach(([key, value]) => { + if (key !== 'weight' && key !== 'updatedAt') { + expect(result[key as keyof typeof result]).toEqual(value); + } + }); + }); }); describe('deleteMany', () => { it('should delete multiple nlp entities', async () => { diff --git a/api/src/nlp/controllers/nlp-entity.controller.ts b/api/src/nlp/controllers/nlp-entity.controller.ts index 0adbce59..a801ff15 100644 --- a/api/src/nlp/controllers/nlp-entity.controller.ts +++ b/api/src/nlp/controllers/nlp-entity.controller.ts @@ -157,10 +157,19 @@ export class NlpEntityController extends BaseController< this.logger.warn(`Unable to update NLP Entity by id ${id}`); throw new NotFoundException(`NLP Entity with ID ${id} not found`); } + if (nlpEntity.builtin) { - throw new MethodNotAllowedException( - `Cannot update builtin NLP Entity ${nlpEntity.name}`, - ); + // Only allow weight update for builtin entities + if (updateNlpEntityDto.weight) { + return await this.nlpEntityService.updateWeight( + id, + updateNlpEntityDto.weight, + ); + } else { + throw new MethodNotAllowedException( + `Cannot update builtin NLP Entity ${nlpEntity.name} except for weight`, + ); + } } return await this.nlpEntityService.updateOne(id, updateNlpEntityDto); diff --git a/api/src/nlp/controllers/nlp-sample.controller.spec.ts b/api/src/nlp/controllers/nlp-sample.controller.spec.ts index 4da14ed0..031c5dac 100644 --- a/api/src/nlp/controllers/nlp-sample.controller.spec.ts +++ b/api/src/nlp/controllers/nlp-sample.controller.spec.ts @@ -372,6 +372,7 @@ describe('NlpSampleController', () => { lookups: ['trait'], doc: '', builtin: false, + weight: 1, }; const priceValueEntity = await nlpEntityService.findOne({ name: 'intent', diff --git a/api/src/nlp/controllers/nlp-value.controller.spec.ts b/api/src/nlp/controllers/nlp-value.controller.spec.ts index a72b8571..be6af201 100644 --- a/api/src/nlp/controllers/nlp-value.controller.spec.ts +++ b/api/src/nlp/controllers/nlp-value.controller.spec.ts @@ -6,6 +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 { CACHE_MANAGER } from '@nestjs/cache-manager'; import { BadRequestException, NotFoundException } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; @@ -57,6 +58,12 @@ describe('NlpValueController', () => { NlpSampleEntityRepository, NlpEntityService, NlpEntityRepository, + { + provide: CACHE_MANAGER, + useValue: { + del: jest.fn(), + }, + }, ], }); [nlpValueController, nlpValueService, nlpEntityService] = await getMocks([ diff --git a/api/src/nlp/dto/nlp-entity.dto.ts b/api/src/nlp/dto/nlp-entity.dto.ts index 009b1841..98c1002b 100644 --- a/api/src/nlp/dto/nlp-entity.dto.ts +++ b/api/src/nlp/dto/nlp-entity.dto.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. @@ -11,10 +11,13 @@ import { IsArray, IsBoolean, IsIn, + IsInt, IsNotEmpty, + IsNumber, IsOptional, IsString, Matches, + Min, } from 'class-validator'; import { DtoConfig } from '@/utils/types/dto.types'; @@ -47,6 +50,17 @@ export class NlpEntityCreateDto { @IsBoolean() @IsOptional() builtin?: boolean; + + @ApiPropertyOptional({ + description: 'Nlp entity associated weight for next block triggering', + type: Number, + minimum: 1, + }) + @IsNumber() + @IsOptional() + @Min(1, { message: 'Weight must be a positive integer' }) + @IsInt({ message: 'Weight must be an integer' }) + weight?: number; } export type NlpEntityDto = DtoConfig<{ diff --git a/api/src/nlp/nlp.module.ts b/api/src/nlp/nlp.module.ts index 342ffa1f..4c783bd8 100644 --- a/api/src/nlp/nlp.module.ts +++ b/api/src/nlp/nlp.module.ts @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Hexastack. All rights reserved. + * Copyright © 2025 Hexastack. All rights reserved. * * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. diff --git a/api/src/nlp/schemas/nlp-entity.schema.ts b/api/src/nlp/schemas/nlp-entity.schema.ts index 86085d05..0c61c0c8 100644 --- a/api/src/nlp/schemas/nlp-entity.schema.ts +++ b/api/src/nlp/schemas/nlp-entity.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. @@ -58,6 +58,12 @@ export class NlpEntityStub extends BaseSchema { @Prop({ type: Boolean, default: false }) builtin: boolean; + /** + * Entity's weight used to determine the next block to trigger in the conversational flow. + */ + @Prop({ type: Number, default: 1, min: 0 }) + weight: number; + /** * Returns a map object for entities * @param entities - Array of entities diff --git a/api/src/nlp/schemas/types.ts b/api/src/nlp/schemas/types.ts index 482fdf57..96b7dae4 100644 --- a/api/src/nlp/schemas/types.ts +++ b/api/src/nlp/schemas/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. @@ -25,3 +25,11 @@ export enum NlpSampleState { test = 'test', inbox = 'inbox', } + +export type NlpCacheMap = Map; + +export type NlpCacheMapValues = { + id: string; + weight: number; + values: string[]; +}; diff --git a/api/src/nlp/services/nlp-entity.service.spec.ts b/api/src/nlp/services/nlp-entity.service.spec.ts index 53118e95..6e55f9ef 100644 --- a/api/src/nlp/services/nlp-entity.service.spec.ts +++ b/api/src/nlp/services/nlp-entity.service.spec.ts @@ -6,6 +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 { CACHE_MANAGER } from '@nestjs/cache-manager'; import { MongooseModule } from '@nestjs/mongoose'; import { nlpEntityFixtures } from '@/utils/test/fixtures/nlpentity'; @@ -20,7 +21,11 @@ import { buildTestingMocks } from '@/utils/test/utils'; import { NlpEntityRepository } from '../repositories/nlp-entity.repository'; import { NlpSampleEntityRepository } from '../repositories/nlp-sample-entity.repository'; import { NlpValueRepository } from '../repositories/nlp-value.repository'; -import { NlpEntity, NlpEntityModel } from '../schemas/nlp-entity.schema'; +import { + NlpEntity, + NlpEntityFull, + NlpEntityModel, +} from '../schemas/nlp-entity.schema'; import { NlpSampleEntityModel } from '../schemas/nlp-sample-entity.schema'; import { NlpValueModel } from '../schemas/nlp-value.schema'; @@ -48,6 +53,12 @@ describe('nlpEntityService', () => { NlpValueService, NlpValueRepository, NlpSampleEntityRepository, + { + provide: CACHE_MANAGER, + useValue: { + del: jest.fn(), + }, + }, ], }); [nlpEntityService, nlpEntityRepository, nlpValueRepository] = @@ -117,6 +128,77 @@ describe('nlpEntityService', () => { expect(result).toEqualPayload(entitiesWithValues); }); }); + describe('NlpEntityService - updateWeight', () => { + let createdEntity: NlpEntity; + beforeEach(async () => { + createdEntity = await nlpEntityRepository.create({ + name: 'testentity', + builtin: false, + weight: 3, + }); + }); + + it('should update the weight of an NLP entity', async () => { + const newWeight = 8; + + const updatedEntity = await nlpEntityService.updateWeight( + createdEntity.id, + newWeight, + ); + + expect(updatedEntity.weight).toBe(newWeight); + }); + + it('should handle updating weight of non-existent entity', async () => { + const nonExistentId = '507f1f77bcf86cd799439011'; // Example MongoDB ObjectId + + try { + await nlpEntityService.updateWeight(nonExistentId, 5); + fail('Expected error was not thrown'); + } catch (error) { + expect(error).toBeDefined(); + } + }); + + it('should use default weight of 1 when creating entity without weight', async () => { + const createdEntity = await nlpEntityRepository.create({ + name: 'entityWithoutWeight', + builtin: true, + // weight not specified + }); + + expect(createdEntity.weight).toBe(1); + }); + + it('should throw an error if weight is less than 1', async () => { + const invalidWeight = 0; + + await expect( + nlpEntityService.updateWeight(createdEntity.id, invalidWeight), + ).rejects.toThrow('Weight must be a positive integer'); + }); + + it('should throw an error if weight is a decimal', async () => { + const invalidWeight = 2.5; + + await expect( + nlpEntityService.updateWeight(createdEntity.id, invalidWeight), + ).rejects.toThrow('Weight must be a positive integer'); + }); + + it('should throw an error if weight is negative', async () => { + const invalidWeight = -3; + + await expect( + nlpEntityService.updateWeight(createdEntity.id, invalidWeight), + ).rejects.toThrow('Weight must be a positive integer'); + }); + + afterEach(async () => { + // Clean the collection after each test + await nlpEntityRepository.deleteOne(createdEntity.id); + }); + }); describe('storeNewEntities', () => { it('should store new entities', async () => { @@ -150,4 +232,47 @@ describe('nlpEntityService', () => { expect(result).toEqualPayload(storedEntites); }); }); + describe('getNlpMap', () => { + it('should return a NlpCacheMap with the correct structure', async () => { + // Arrange + const firstMockValues = { + id: '1', + weight: 1, + }; + const firstMockEntity = { + name: 'intent', + ...firstMockValues, + values: [{ value: 'buy' }, { value: 'sell' }], + } as unknown as Partial; + const secondMockValues = { + id: '2', + weight: 5, + }; + const secondMockEntity = { + name: 'subject', + ...secondMockValues, + values: [{ value: 'product' }], + } as unknown as Partial; + const mockEntities = [firstMockEntity, secondMockEntity]; + + // Mock findAndPopulate + jest + .spyOn(nlpEntityService, 'findAllAndPopulate') + .mockResolvedValue(mockEntities as unknown as NlpEntityFull[]); + + // Act + const result = await nlpEntityService.getNlpMap(); + + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(2); + expect(result.get('intent')).toEqual({ + name: 'intent', + ...firstMockEntity, + }); + expect(result.get('subject')).toEqual({ + name: 'subject', + ...secondMockEntity, + }); + }); + }); }); diff --git a/api/src/nlp/services/nlp-entity.service.ts b/api/src/nlp/services/nlp-entity.service.ts index e8531e95..09e233c1 100644 --- a/api/src/nlp/services/nlp-entity.service.ts +++ b/api/src/nlp/services/nlp-entity.service.ts @@ -1,13 +1,18 @@ /* - * 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 { Injectable } from '@nestjs/common'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Inject, Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { Cache } from 'cache-manager'; +import { NLP_MAP_CACHE_KEY } from '@/utils/constants/cache'; +import { Cacheable } from '@/utils/decorators/cacheable.decorator'; import { BaseService } from '@/utils/generics/base-service'; import { Lookup, NlpEntityDto } from '../dto/nlp-entity.dto'; @@ -17,7 +22,7 @@ import { NlpEntityFull, NlpEntityPopulate, } from '../schemas/nlp-entity.schema'; -import { NlpSampleEntityValue } from '../schemas/types'; +import { NlpCacheMap, NlpSampleEntityValue } from '../schemas/types'; import { NlpValueService } from './nlp-value.service'; @@ -30,6 +35,7 @@ export class NlpEntityService extends BaseService< > { constructor( readonly repository: NlpEntityRepository, + @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, private readonly nlpValueService: NlpValueService, ) { super(repository); @@ -46,6 +52,28 @@ export class NlpEntityService extends BaseService< return await this.repository.deleteOne(id); } + /** + * Updates the `weight` field of a specific NLP entity by its ID. + * + * This method is part of the NLP-based blocks prioritization strategy. + * The weight influences the scoring of blocks when multiple blocks match a user's input. + * @param id - The unique identifier of the entity to update. + * @param updatedWeight - The new weight to assign. Must be a positive integer. + * @throws Error if the weight is not a positive integer. + * @returns A promise that resolves to the updated entity. + */ + async updateWeight(id: string, updatedWeight: number): Promise { + if (!Number.isInteger(updatedWeight) || updatedWeight < 1) { + throw new Error('Weight must be a positive integer'); + } + + return await this.repository.updateOne( + id, + { weight: updatedWeight }, + { new: true }, + ); + } + /** * Stores new entities based on the sample text and sample entities. * Deletes all values relative to this entity before deleting the entity itself. @@ -97,4 +125,49 @@ export class NlpEntityService extends BaseService< ); return Promise.all(findOrCreate); } + + /** + * Clears the NLP map cache + */ + async clearCache() { + await this.cacheManager.del(NLP_MAP_CACHE_KEY); + } + + /** + * Event handler for Nlp Entity updates. Listens to 'hook:nlpEntity:*' events + * and invalidates the cache for nlp entities when triggered. + */ + @OnEvent('hook:nlpEntity:*') + async handleNlpEntityUpdateEvent() { + this.clearCache(); + } + + /** + * Event handler for Nlp Value updates. Listens to 'hook:nlpValue:*' events + * and invalidates the cache for nlp values when triggered. + */ + @OnEvent('hook:nlpValue:*') + async handleNlpValueUpdateEvent() { + this.clearCache(); + } + + /** + * Retrieves NLP entity lookup information for the given list of entity names. + * + * This method queries the database for lookups that match any of the provided + * entity names, transforms the result into a map structure where each key is + * the entity name and each value contains metadata (id, weight, and list of values), + * and caches the result using the configured cache key. + * + * @param entityNames - Array of entity names to retrieve lookup data for. + * @returns A Promise that resolves to a map of entity name to its corresponding lookup metadata. + */ + @Cacheable(NLP_MAP_CACHE_KEY) + async getNlpMap(): Promise { + const entities = await this.findAllAndPopulate(); + return entities.reduce((acc, curr) => { + acc.set(curr.name, curr); + return acc; + }, new Map()); + } } diff --git a/api/src/nlp/services/nlp-sample-entity.service.spec.ts b/api/src/nlp/services/nlp-sample-entity.service.spec.ts index ed2be6f1..44c153bb 100644 --- a/api/src/nlp/services/nlp-sample-entity.service.spec.ts +++ b/api/src/nlp/services/nlp-sample-entity.service.spec.ts @@ -6,6 +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 { CACHE_MANAGER } from '@nestjs/cache-manager'; import { MongooseModule } from '@nestjs/mongoose'; import { LanguageRepository } from '@/i18n/repositories/language.repository'; @@ -76,6 +77,12 @@ describe('NlpSampleEntityService', () => { NlpSampleEntityService, NlpEntityService, NlpValueService, + { + provide: CACHE_MANAGER, + useValue: { + del: jest.fn(), + }, + }, ], }); [ diff --git a/api/src/nlp/services/nlp-value.service.spec.ts b/api/src/nlp/services/nlp-value.service.spec.ts index 3f999144..eacae606 100644 --- a/api/src/nlp/services/nlp-value.service.spec.ts +++ b/api/src/nlp/services/nlp-value.service.spec.ts @@ -6,6 +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 { CACHE_MANAGER } from '@nestjs/cache-manager'; import { MongooseModule } from '@nestjs/mongoose'; import { BaseSchema } from '@/utils/generics/base-schema'; @@ -58,6 +59,12 @@ describe('NlpValueService', () => { NlpEntityRepository, NlpValueService, NlpEntityService, + { + provide: CACHE_MANAGER, + useValue: { + del: jest.fn(), + }, + }, ], }); [ diff --git a/api/src/utils/constants/cache.ts b/api/src/utils/constants/cache.ts index ccbf392a..91dc30d0 100644 --- a/api/src/utils/constants/cache.ts +++ b/api/src/utils/constants/cache.ts @@ -18,3 +18,5 @@ export const LANGUAGES_CACHE_KEY = 'languages'; export const DEFAULT_LANGUAGE_CACHE_KEY = 'default_language'; export const ALLOWED_ORIGINS_CACHE_KEY = 'allowed_origins'; + +export const NLP_MAP_CACHE_KEY = 'nlp_map'; diff --git a/api/src/utils/test/fixtures/nlpentity.ts b/api/src/utils/test/fixtures/nlpentity.ts index 16902dd8..deb44e97 100644 --- a/api/src/utils/test/fixtures/nlpentity.ts +++ b/api/src/utils/test/fixtures/nlpentity.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. @@ -17,18 +17,21 @@ export const nlpEntityFixtures: NlpEntityCreateDto[] = [ lookups: ['trait'], doc: '', builtin: false, + weight: 1, }, { name: 'first_name', lookups: ['keywords'], doc: '', builtin: false, + weight: 1, }, { name: 'built_in', lookups: ['trait'], doc: '', builtin: true, + weight: 1, }, ]; diff --git a/api/src/utils/test/mocks/block.ts b/api/src/utils/test/mocks/block.ts index 48dcdd47..e58066f6 100644 --- a/api/src/utils/test/mocks/block.ts +++ b/api/src/utils/test/mocks/block.ts @@ -16,7 +16,7 @@ import { ButtonType, PayloadType } from '@/chat/schemas/types/button'; import { CaptureVar } from '@/chat/schemas/types/capture-var'; import { OutgoingMessageFormat } from '@/chat/schemas/types/message'; import { BlockOptions, ContentOptions } from '@/chat/schemas/types/options'; -import { Pattern } from '@/chat/schemas/types/pattern'; +import { NlpPattern, Pattern } from '@/chat/schemas/types/pattern'; import { QuickReplyType } from '@/chat/schemas/types/quick-reply'; import { modelInstance } from './misc'; @@ -246,6 +246,121 @@ export const blockGetStarted = { message: ['Welcome! How are you ? '], } as unknown as BlockFull; +export const mockNlpPatternsSetOne: NlpPattern[] = [ + { + entity: 'intent', + match: 'value', + value: 'greeting', + }, + { + entity: 'firstname', + match: 'value', + value: 'jhon', + }, +]; + +export const mockNlpPatternsSetTwo: NlpPattern[] = [ + { + entity: 'intent', + match: 'value', + value: 'affirmation', + }, + { + entity: 'firstname', + match: 'value', + value: 'mark', + }, +]; + +export const mockNlpPatternsSetThree: NlpPattern[] = [ + { + entity: 'intent', + match: 'value', + value: 'greeting', + }, + { + entity: 'firstname', + match: 'entity', + }, +]; + +export const mockNlpBlock: BlockFull = { + ...baseBlockInstance, + name: 'Mock Nlp', + patterns: [ + 'Hello', + '/we*lcome/', + { label: 'Mock Nlp', value: 'MOCK_NLP' }, + + mockNlpPatternsSetOne, + [ + { + entity: 'intent', + match: 'value', + value: 'greeting', + }, + { + entity: 'firstname', + match: 'value', + value: 'doe', + }, + ], + ], + + trigger_labels: customerLabelsMock, + message: ['Good to see you again '], +} as unknown as BlockFull; + +export const mockModifiedNlpBlock: BlockFull = { + ...baseBlockInstance, + name: 'Modified Mock Nlp', + patterns: [ + 'Hello', + '/we*lcome/', + { label: 'Modified Mock Nlp', value: 'MODIFIED_MOCK_NLP' }, + mockNlpPatternsSetThree, + ], + trigger_labels: customerLabelsMock, + message: ['Hello there'], +} as unknown as BlockFull; + +export const mockModifiedNlpBlockOne: BlockFull = { + ...baseBlockInstance, + name: 'Modified Mock Nlp One', + patterns: [ + 'Hello', + '/we*lcome/', + { label: 'Modified Mock Nlp One', value: 'MODIFIED_MOCK_NLP_ONE' }, + mockNlpPatternsSetTwo, + [ + { + entity: 'firstname', + match: 'entity', + }, + ], + ], + trigger_labels: customerLabelsMock, + message: ['Hello Sir'], +} as unknown as BlockFull; + +export const mockModifiedNlpBlockTwo: BlockFull = { + ...baseBlockInstance, + name: 'Modified Mock Nlp Two', + patterns: [ + 'Hello', + '/we*lcome/', + { label: 'Modified Mock Nlp Two', value: 'MODIFIED_MOCK_NLP_TWO' }, + [ + { + entity: 'firstname', + match: 'entity', + }, + ], + mockNlpPatternsSetThree, + ], + trigger_labels: customerLabelsMock, + message: ['Hello Madam'], +} as unknown as BlockFull; const patternsProduct: Pattern[] = [ 'produit', [ @@ -285,3 +400,5 @@ export const blockCarouselMock = { } as unknown as BlockFull; export const blocks: BlockFull[] = [blockGetStarted, blockEmpty]; + +export const nlpBlocks: BlockFull[] = [blockGetStarted, mockNlpBlock]; diff --git a/api/src/utils/test/mocks/nlp.ts b/api/src/utils/test/mocks/nlp.ts index 04a6e0bd..a88b3bbd 100644 --- a/api/src/utils/test/mocks/nlp.ts +++ b/api/src/utils/test/mocks/nlp.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. @@ -7,6 +7,7 @@ */ import { NLU } from '@/helper/types'; +import { NlpCacheMap } from '@/nlp/schemas/types'; export const nlpEntitiesGreeting: NLU.ParseEntities = { entities: [ @@ -27,3 +28,52 @@ export const nlpEntitiesGreeting: NLU.ParseEntities = { }, ], }; + +export const mockNlpEntitiesSetOne: NLU.ParseEntities = { + entities: [ + { + entity: 'intent', + value: 'greeting', + confidence: 0.999, + }, + { + entity: 'firstname', + value: 'jhon', + confidence: 0.5, + }, + ], +}; + +export const mockNlpEntitiesSetTwo: NLU.ParseEntities = { + entities: [ + { + entity: 'intent', + value: 'greeting', + confidence: 0.94, + }, + { + entity: 'firstname', + value: 'doe', + confidence: 0.33, + }, + ], +}; + +export const mockNlpCacheMap: NlpCacheMap = new Map([ + [ + 'intent', + { + id: '67e3e41eff551ca5be70559c', + weight: 1, + values: ['greeting', 'affirmation'], + }, + ], + [ + 'firstname', + { + id: '67e3e41eff551ca5be70559d', + weight: 1, + values: ['jhon', 'doe'], + }, + ], +]); diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 5b204ff0..1854a365 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -121,7 +121,9 @@ "file_error": "File not found", "audio_error": "Audio not found", "video_error": "Video not found", - "missing_fields_error": "Please make sure that all required fields are filled" + "missing_fields_error": "Please make sure that all required fields are filled", + "weight_required_error": "Weight is required or invalid", + "weight_positive_integer_error": "Weight must be a positive integer" }, "menu": { "terms": "Terms of Use", @@ -348,6 +350,7 @@ "nlp_lookup_trait": "Trait", "doc": "Documentation", "builtin": "Built-in?", + "weight": "Weight", "dataset": "Dataset", "yes": "Yes", "no": "No", diff --git a/frontend/public/locales/fr/translation.json b/frontend/public/locales/fr/translation.json index ce8c5352..8a80e491 100644 --- a/frontend/public/locales/fr/translation.json +++ b/frontend/public/locales/fr/translation.json @@ -121,7 +121,9 @@ "file_error": "Fichier introuvable", "audio_error": "Audio introuvable", "video_error": "Vidéo introuvable", - "missing_fields_error": "Veuillez vous assurer que tous les champs sont remplis correctement" + "missing_fields_error": "Veuillez vous assurer que tous les champs sont remplis correctement", + "weight_positive_integer_error": "Le poids doit être un nombre entier positif", + "weight_required_error": "Le poids est requis ou bien invalide" }, "menu": { "terms": "Conditions d'utilisation", @@ -347,6 +349,7 @@ "nlp_lookup_trait": "Trait", "synonyms": "Synonymes", "doc": "Documentation", + "weight": "Poids", "builtin": "Intégré?", "dataset": "Données", "yes": "Oui", diff --git a/frontend/src/app-components/tables/columns/getColumns.tsx b/frontend/src/app-components/tables/columns/getColumns.tsx index 0c43fc86..46bd269e 100644 --- a/frontend/src/app-components/tables/columns/getColumns.tsx +++ b/frontend/src/app-components/tables/columns/getColumns.tsx @@ -156,8 +156,7 @@ function StackComponent({ disabled={ (isDisabled && isDisabled(params.row)) || (params.row.builtin && - (requires.includes(PermissionAction.UPDATE) || - requires.includes(PermissionAction.DELETE))) + requires.includes(PermissionAction.DELETE)) } onClick={() => { action && action(params.row); diff --git a/frontend/src/components/nlp/components/NlpEntity.tsx b/frontend/src/components/nlp/components/NlpEntity.tsx index 08489d69..83b7c959 100644 --- a/frontend/src/components/nlp/components/NlpEntity.tsx +++ b/frontend/src/components/nlp/components/NlpEntity.tsx @@ -167,6 +167,16 @@ const NlpEntity = () => { resizable: false, renderHeader, }, + { + maxWidth: 210, + field: "weight", + headerName: t("label.weight"), + renderCell: (val) => , + sortable: true, + disableColumnMenu: true, + resizable: false, + renderHeader, + }, { maxWidth: 90, field: "builtin", diff --git a/frontend/src/components/nlp/components/NlpEntityForm.tsx b/frontend/src/components/nlp/components/NlpEntityForm.tsx index 1451a190..a5c3c270 100644 --- a/frontend/src/components/nlp/components/NlpEntityForm.tsx +++ b/frontend/src/components/nlp/components/NlpEntityForm.tsx @@ -60,6 +60,7 @@ export const NlpEntityVarForm: FC> = ({ name: nlpEntity?.name || "", doc: nlpEntity?.doc || "", lookups: nlpEntity?.lookups || ["keywords"], + weight: nlpEntity?.weight || 1, }, }); const validationRules = { @@ -82,6 +83,7 @@ export const NlpEntityVarForm: FC> = ({ reset({ name: nlpEntity.name, doc: nlpEntity.doc, + weight: nlpEntity.weight, }); } else { reset(); @@ -121,6 +123,7 @@ export const NlpEntityVarForm: FC> = ({ required autoFocus helperText={errors.name ? errors.name.message : null} + disabled={nlpEntity?.builtin} /> @@ -128,8 +131,35 @@ export const NlpEntityVarForm: FC> = ({ label={t("label.doc")} {...register("doc")} multiline={true} + disabled={nlpEntity?.builtin} /> + + + value && Number.isInteger(value) && value! > 0 + ? true + : t("message.weight_positive_integer_error"), + })} + type="number" + inputProps={{ + min: 1, + step: 1, + inputMode: "numeric", + pattern: "[1-9][0-9]*", + }} + error={!!errors.weight} + helperText={errors.weight?.message} + /> + diff --git a/frontend/src/types/nlp-entity.types.ts b/frontend/src/types/nlp-entity.types.ts index 2d0624e8..97c0ddce 100644 --- a/frontend/src/types/nlp-entity.types.ts +++ b/frontend/src/types/nlp-entity.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. @@ -19,6 +19,7 @@ export interface INlpEntityAttributes { lookups: Lookup[]; doc?: string; builtin?: boolean; + weight?: number; } export enum NlpLookups { From 04a008b6fde0884015a39f7319258ed9e34cb04b Mon Sep 17 00:00:00 2001 From: MohamedAliBouhaouala Date: Tue, 6 May 2025 15:34:21 +0100 Subject: [PATCH 02/15] fix: remove unsued utils --- api/src/chat/schemas/types/pattern.ts | 18 ------------------ api/src/nlp/dto/nlp-entity.dto.ts | 2 -- 2 files changed, 20 deletions(-) diff --git a/api/src/chat/schemas/types/pattern.ts b/api/src/chat/schemas/types/pattern.ts index 6b430f69..48df5efe 100644 --- a/api/src/chat/schemas/types/pattern.ts +++ b/api/src/chat/schemas/types/pattern.ts @@ -8,8 +8,6 @@ import { z } from 'zod'; -import { BlockFull } from '../block.schema'; - import { PayloadType } from './button'; export const payloadPatternSchema = z.object({ @@ -59,19 +57,3 @@ export const patternSchema = z.union([ ]); export type Pattern = z.infer; - -export type NlpPatternMatchResult = { - block: BlockFull; - matchedPattern: NlpPattern[]; -}; - -export function isNlpPattern(pattern: NlpPattern) { - return ( - (typeof pattern === 'object' && - pattern !== null && - 'entity' in pattern && - 'match' in pattern && - pattern.match === 'entity') || - pattern.match === 'value' - ); -} diff --git a/api/src/nlp/dto/nlp-entity.dto.ts b/api/src/nlp/dto/nlp-entity.dto.ts index 98c1002b..8c5c49e7 100644 --- a/api/src/nlp/dto/nlp-entity.dto.ts +++ b/api/src/nlp/dto/nlp-entity.dto.ts @@ -13,7 +13,6 @@ import { IsIn, IsInt, IsNotEmpty, - IsNumber, IsOptional, IsString, Matches, @@ -56,7 +55,6 @@ export class NlpEntityCreateDto { type: Number, minimum: 1, }) - @IsNumber() @IsOptional() @Min(1, { message: 'Weight must be a positive integer' }) @IsInt({ message: 'Weight must be an integer' }) From 003c6924f83e74b7f8dbdd6090880d5ba873d0ac Mon Sep 17 00:00:00 2001 From: MohamedAliBouhaouala Date: Tue, 6 May 2025 15:41:02 +0100 Subject: [PATCH 03/15] fix: remove stale comments --- api/src/chat/services/block.service.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index 65d5ef2b..930353db 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -186,15 +186,6 @@ export class BlockService extends BaseService< // Perform an NLP Match if (!block && nlp) { - // Use the `reduce` function to iterate over `filteredBlocks` and accumulate a new array `matchesWithPatterns`. - // This approach combines the matching of NLP patterns and filtering of blocks with empty or invalid matches - // into a single operation. This avoids the need for a separate mapping and filtering step, improving performance. - // For each block in `filteredBlocks`, we call `matchNLP` to find patterns that match the NLP data. - // If `matchNLP` returns a non-empty list of matched patterns, the block and its matched patterns are added - // to the accumulator array `acc`, which is returned as the final result. - // This ensures that only blocks with valid matches are kept, and blocks with no matches are excluded, - // all while iterating through the list only once. - block = await this.matchBestNLP(filteredBlocks, nlp); } } From 5dcd36be98b98812b2790bc69754c84ffe89b627 Mon Sep 17 00:00:00 2001 From: MohamedAliBouhaouala Date: Tue, 6 May 2025 17:02:47 +0100 Subject: [PATCH 04/15] feat: set nlpEntity weights type to float --- api/src/chat/services/block.service.ts | 2 -- .../controllers/nlp-entity.controller.spec.ts | 2 +- api/src/nlp/dto/nlp-entity.dto.ts | 7 +++---- .../nlp/services/nlp-entity.service.spec.ts | 18 +----------------- api/src/nlp/services/nlp-entity.service.ts | 8 ++++---- frontend/public/locales/en/translation.json | 2 +- frontend/public/locales/fr/translation.json | 2 +- .../nlp/components/NlpEntityForm.tsx | 12 ++++++------ 8 files changed, 17 insertions(+), 36 deletions(-) diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index 930353db..d0afb2d5 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -384,8 +384,6 @@ export class BlockService extends BaseService< * * @param patterns - The NLP patterns associated with the block. * @param nlp - The parsed NLP entities from the user input. - * @param nlpCacheMap - A cache to reuse fetched entity metadata (e.g., weights and valid values). - * @param nlpPenaltyFactor - A multiplier applied to scores when the pattern match type is 'entity'. * @returns A numeric score representing how well the block matches the given NLP context. */ async calculateBlockScore( diff --git a/api/src/nlp/controllers/nlp-entity.controller.spec.ts b/api/src/nlp/controllers/nlp-entity.controller.spec.ts index 8eadf357..6a0189bb 100644 --- a/api/src/nlp/controllers/nlp-entity.controller.spec.ts +++ b/api/src/nlp/controllers/nlp-entity.controller.spec.ts @@ -311,7 +311,7 @@ describe('NlpEntityController', () => { doc: '', lookups: ['trait'], builtin: false, - weight: 4, + weight: 8, }; const originalEntity: NlpEntity | null = await nlpEntityService.findOne( buitInEntityId!, diff --git a/api/src/nlp/dto/nlp-entity.dto.ts b/api/src/nlp/dto/nlp-entity.dto.ts index 8c5c49e7..c5d0d58b 100644 --- a/api/src/nlp/dto/nlp-entity.dto.ts +++ b/api/src/nlp/dto/nlp-entity.dto.ts @@ -11,8 +11,8 @@ import { IsArray, IsBoolean, IsIn, - IsInt, IsNotEmpty, + IsNumber, IsOptional, IsString, Matches, @@ -53,11 +53,10 @@ export class NlpEntityCreateDto { @ApiPropertyOptional({ description: 'Nlp entity associated weight for next block triggering', type: Number, - minimum: 1, }) @IsOptional() - @Min(1, { message: 'Weight must be a positive integer' }) - @IsInt({ message: 'Weight must be an integer' }) + @Min(0.01, { message: 'Weight must be positive' }) + @IsNumber() weight?: number; } diff --git a/api/src/nlp/services/nlp-entity.service.spec.ts b/api/src/nlp/services/nlp-entity.service.spec.ts index 6e55f9ef..5b11e52a 100644 --- a/api/src/nlp/services/nlp-entity.service.spec.ts +++ b/api/src/nlp/services/nlp-entity.service.spec.ts @@ -170,28 +170,12 @@ describe('nlpEntityService', () => { expect(createdEntity.weight).toBe(1); }); - it('should throw an error if weight is less than 1', async () => { - const invalidWeight = 0; - - await expect( - nlpEntityService.updateWeight(createdEntity.id, invalidWeight), - ).rejects.toThrow('Weight must be a positive integer'); - }); - - it('should throw an error if weight is a decimal', async () => { - const invalidWeight = 2.5; - - await expect( - nlpEntityService.updateWeight(createdEntity.id, invalidWeight), - ).rejects.toThrow('Weight must be a positive integer'); - }); - it('should throw an error if weight is negative', async () => { const invalidWeight = -3; await expect( nlpEntityService.updateWeight(createdEntity.id, invalidWeight), - ).rejects.toThrow('Weight must be a positive integer'); + ).rejects.toThrow('Weight must be a positive number'); }); afterEach(async () => { diff --git a/api/src/nlp/services/nlp-entity.service.ts b/api/src/nlp/services/nlp-entity.service.ts index 09e233c1..907021b9 100644 --- a/api/src/nlp/services/nlp-entity.service.ts +++ b/api/src/nlp/services/nlp-entity.service.ts @@ -58,13 +58,13 @@ export class NlpEntityService extends BaseService< * This method is part of the NLP-based blocks prioritization strategy. * The weight influences the scoring of blocks when multiple blocks match a user's input. * @param id - The unique identifier of the entity to update. - * @param updatedWeight - The new weight to assign. Must be a positive integer. - * @throws Error if the weight is not a positive integer. + * @param updatedWeight - The new weight to assign. Must be a positive number. + * @throws Error if the weight is not a positive number. * @returns A promise that resolves to the updated entity. */ async updateWeight(id: string, updatedWeight: number): Promise { - if (!Number.isInteger(updatedWeight) || updatedWeight < 1) { - throw new Error('Weight must be a positive integer'); + if (updatedWeight < 0) { + throw new Error('Weight must be a positive number'); } return await this.repository.updateOne( diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 1854a365..92273736 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -123,7 +123,7 @@ "video_error": "Video not found", "missing_fields_error": "Please make sure that all required fields are filled", "weight_required_error": "Weight is required or invalid", - "weight_positive_integer_error": "Weight must be a positive integer" + "weight_positive_number_error": "Weight must be a positive number" }, "menu": { "terms": "Terms of Use", diff --git a/frontend/public/locales/fr/translation.json b/frontend/public/locales/fr/translation.json index 8a80e491..15982b1d 100644 --- a/frontend/public/locales/fr/translation.json +++ b/frontend/public/locales/fr/translation.json @@ -122,7 +122,7 @@ "audio_error": "Audio introuvable", "video_error": "Vidéo introuvable", "missing_fields_error": "Veuillez vous assurer que tous les champs sont remplis correctement", - "weight_positive_integer_error": "Le poids doit être un nombre entier positif", + "weight_positive_number_error": "Le poids doit être un nombre positif", "weight_required_error": "Le poids est requis ou bien invalide" }, "menu": { diff --git a/frontend/src/components/nlp/components/NlpEntityForm.tsx b/frontend/src/components/nlp/components/NlpEntityForm.tsx index a5c3c270..88f8c079 100644 --- a/frontend/src/components/nlp/components/NlpEntityForm.tsx +++ b/frontend/src/components/nlp/components/NlpEntityForm.tsx @@ -141,18 +141,18 @@ export const NlpEntityVarForm: FC> = ({ valueAsNumber: true, required: t("message.weight_required_error"), min: { - value: 1, - message: t("message.weight_positive_integer_error"), + value: 0.01, + message: t("message.weight_positive_number_error"), }, validate: (value) => - value && Number.isInteger(value) && value! > 0 + value && value! > 0 ? true - : t("message.weight_positive_integer_error"), + : t("message.weight_positive_number_error"), })} type="number" inputProps={{ - min: 1, - step: 1, + min: 0.01, + step: 0.01, inputMode: "numeric", pattern: "[1-9][0-9]*", }} From 5d8befacdf46a4aeffe9aadf7d9d0088cb6a6f57 Mon Sep 17 00:00:00 2001 From: MohamedAliBouhaouala Date: Tue, 6 May 2025 19:03:00 +0100 Subject: [PATCH 05/15] fix: minor enhancements --- .../chat/controllers/block.controller.spec.ts | 2 -- api/src/chat/services/block.service.ts | 28 +++++++++---------- .../controllers/nlp-entity.controller.spec.ts | 3 +- .../nlp/controllers/nlp-entity.controller.ts | 3 +- api/src/nlp/dto/nlp-entity.dto.ts | 8 ++++-- api/src/nlp/schemas/nlp-entity.schema.ts | 9 +++++- .../nlp/services/nlp-entity.service.spec.ts | 4 ++- api/src/nlp/services/nlp-entity.service.ts | 22 +++++++++------ frontend/public/locales/en/translation.json | 2 +- frontend/public/locales/fr/translation.json | 2 +- .../nlp/components/NlpEntityForm.tsx | 5 ++-- 11 files changed, 51 insertions(+), 37 deletions(-) diff --git a/api/src/chat/controllers/block.controller.spec.ts b/api/src/chat/controllers/block.controller.spec.ts index 426a19b3..16e1754b 100644 --- a/api/src/chat/controllers/block.controller.spec.ts +++ b/api/src/chat/controllers/block.controller.spec.ts @@ -20,7 +20,6 @@ import { LanguageRepository } from '@/i18n/repositories/language.repository'; import { LanguageModel } from '@/i18n/schemas/language.schema'; import { I18nService } from '@/i18n/services/i18n.service'; import { LanguageService } from '@/i18n/services/language.service'; -import { LoggerService } from '@/logger/logger.service'; import { NlpEntityRepository } from '@/nlp/repositories/nlp-entity.repository'; import { NlpSampleEntityRepository } from '@/nlp/repositories/nlp-sample-entity.repository'; import { NlpValueRepository } from '@/nlp/repositories/nlp-value.repository'; @@ -128,7 +127,6 @@ describe('BlockController', () => { PermissionService, LanguageService, PluginService, - LoggerService, NlpEntityService, NlpEntityRepository, NlpSampleEntityRepository, diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index d0afb2d5..076c6a07 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -395,22 +395,22 @@ export class BlockService extends BaseService< const nlpCacheMap = await this.entityService.getNlpMap(); // @TODO Make nluPenaltyFactor configurable in UI settings const nluPenaltyFactor = 0.95; - // Compute individual pattern scores using the cache - const patternScores: number[] = patterns.map((pattern) => { - const entityData = nlpCacheMap.get(pattern.entity); - if (!entityData) return 0; + const patternScores: number[] = patterns + .filter(({ entity }) => nlpCacheMap.has(entity)) + .map((pattern) => { + const entityData = nlpCacheMap.get(pattern.entity); - const matchedEntity: NLU.ParseEntity | undefined = nlp.entities.find( - (e) => this.matchesEntityData(e, pattern, entityData), - ); + const matchedEntity: NLU.ParseEntity | undefined = nlp.entities.find( + (e) => this.matchesEntityData(e, pattern, entityData!), + ); - return this.computePatternScore( - matchedEntity, - pattern, - entityData, - nluPenaltyFactor, - ); - }); + return this.computePatternScore( + matchedEntity, + pattern, + entityData!, + nluPenaltyFactor, + ); + }); // Sum the scores return patternScores.reduce((sum, score) => sum + score, 0); diff --git a/api/src/nlp/controllers/nlp-entity.controller.spec.ts b/api/src/nlp/controllers/nlp-entity.controller.spec.ts index 6a0189bb..fd70a91b 100644 --- a/api/src/nlp/controllers/nlp-entity.controller.spec.ts +++ b/api/src/nlp/controllers/nlp-entity.controller.spec.ts @@ -9,6 +9,7 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { BadRequestException, + ConflictException, MethodNotAllowedException, NotFoundException, } from '@nestjs/common'; @@ -278,7 +279,7 @@ describe('NlpEntityController', () => { }; await expect( nlpEntityController.updateOne(buitInEntityId!, updateNlpEntity), - ).rejects.toThrow(MethodNotAllowedException); + ).rejects.toThrow(ConflictException); }); it('should update weight if entity is builtin and weight is provided', async () => { diff --git a/api/src/nlp/controllers/nlp-entity.controller.ts b/api/src/nlp/controllers/nlp-entity.controller.ts index a801ff15..af549526 100644 --- a/api/src/nlp/controllers/nlp-entity.controller.ts +++ b/api/src/nlp/controllers/nlp-entity.controller.ts @@ -9,6 +9,7 @@ import { BadRequestException, Body, + ConflictException, Controller, Delete, Get, @@ -166,7 +167,7 @@ export class NlpEntityController extends BaseController< updateNlpEntityDto.weight, ); } else { - throw new MethodNotAllowedException( + throw new ConflictException( `Cannot update builtin NLP Entity ${nlpEntity.name} except for weight`, ); } diff --git a/api/src/nlp/dto/nlp-entity.dto.ts b/api/src/nlp/dto/nlp-entity.dto.ts index c5d0d58b..86ddf7d8 100644 --- a/api/src/nlp/dto/nlp-entity.dto.ts +++ b/api/src/nlp/dto/nlp-entity.dto.ts @@ -16,7 +16,7 @@ import { IsOptional, IsString, Matches, - Min, + Validate, } from 'class-validator'; import { DtoConfig } from '@/utils/types/dto.types'; @@ -55,8 +55,10 @@ export class NlpEntityCreateDto { type: Number, }) @IsOptional() - @Min(0.01, { message: 'Weight must be positive' }) - @IsNumber() + @Validate((value) => value > 0, { + message: 'Weight must be a strictly positive number', + }) + @IsNumber({ allowNaN: false, allowInfinity: false }) weight?: number; } diff --git a/api/src/nlp/schemas/nlp-entity.schema.ts b/api/src/nlp/schemas/nlp-entity.schema.ts index 0c61c0c8..a5879d5d 100644 --- a/api/src/nlp/schemas/nlp-entity.schema.ts +++ b/api/src/nlp/schemas/nlp-entity.schema.ts @@ -61,7 +61,14 @@ export class NlpEntityStub extends BaseSchema { /** * Entity's weight used to determine the next block to trigger in the conversational flow. */ - @Prop({ type: Number, default: 1, min: 0 }) + @Prop({ + type: Number, + default: 1, + validate: { + validator: (value: number) => value > 0, + message: 'Weight must be a strictly positive number', + }, + }) weight: number; /** diff --git a/api/src/nlp/services/nlp-entity.service.spec.ts b/api/src/nlp/services/nlp-entity.service.spec.ts index 5b11e52a..d90e6ff9 100644 --- a/api/src/nlp/services/nlp-entity.service.spec.ts +++ b/api/src/nlp/services/nlp-entity.service.spec.ts @@ -57,6 +57,8 @@ describe('nlpEntityService', () => { provide: CACHE_MANAGER, useValue: { del: jest.fn(), + set: jest.fn(), + get: jest.fn(), }, }, ], @@ -175,7 +177,7 @@ describe('nlpEntityService', () => { await expect( nlpEntityService.updateWeight(createdEntity.id, invalidWeight), - ).rejects.toThrow('Weight must be a positive number'); + ).rejects.toThrow('Weight must be a strictly positive number'); }); afterEach(async () => { diff --git a/api/src/nlp/services/nlp-entity.service.ts b/api/src/nlp/services/nlp-entity.service.ts index 907021b9..c92b7888 100644 --- a/api/src/nlp/services/nlp-entity.service.ts +++ b/api/src/nlp/services/nlp-entity.service.ts @@ -63,15 +63,11 @@ export class NlpEntityService extends BaseService< * @returns A promise that resolves to the updated entity. */ async updateWeight(id: string, updatedWeight: number): Promise { - if (updatedWeight < 0) { - throw new Error('Weight must be a positive number'); + if (updatedWeight <= 0) { + throw new Error('Weight must be a strictly positive number'); } - return await this.repository.updateOne( - id, - { weight: updatedWeight }, - { new: true }, - ); + return await this.repository.updateOne(id, { weight: updatedWeight }); } /** @@ -139,7 +135,11 @@ export class NlpEntityService extends BaseService< */ @OnEvent('hook:nlpEntity:*') async handleNlpEntityUpdateEvent() { - this.clearCache(); + try { + await this.clearCache(); + } catch (error) { + this.logger.error('Failed to clear NLP entity cache', error); + } } /** @@ -148,7 +148,11 @@ export class NlpEntityService extends BaseService< */ @OnEvent('hook:nlpValue:*') async handleNlpValueUpdateEvent() { - this.clearCache(); + try { + await this.clearCache(); + } catch (error) { + this.logger.error('Failed to clear NLP value cache', error); + } } /** diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 92273736..f3633259 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -123,7 +123,7 @@ "video_error": "Video not found", "missing_fields_error": "Please make sure that all required fields are filled", "weight_required_error": "Weight is required or invalid", - "weight_positive_number_error": "Weight must be a positive number" + "weight_positive_number_error": "Weight must be a strictly positive number" }, "menu": { "terms": "Terms of Use", diff --git a/frontend/public/locales/fr/translation.json b/frontend/public/locales/fr/translation.json index 15982b1d..5ff646ea 100644 --- a/frontend/public/locales/fr/translation.json +++ b/frontend/public/locales/fr/translation.json @@ -122,7 +122,7 @@ "audio_error": "Audio introuvable", "video_error": "Vidéo introuvable", "missing_fields_error": "Veuillez vous assurer que tous les champs sont remplis correctement", - "weight_positive_number_error": "Le poids doit être un nombre positif", + "weight_positive_number_error": "Le poids doit être un nombre strictement positif", "weight_required_error": "Le poids est requis ou bien invalide" }, "menu": { diff --git a/frontend/src/components/nlp/components/NlpEntityForm.tsx b/frontend/src/components/nlp/components/NlpEntityForm.tsx index 88f8c079..1fd49c84 100644 --- a/frontend/src/components/nlp/components/NlpEntityForm.tsx +++ b/frontend/src/components/nlp/components/NlpEntityForm.tsx @@ -145,16 +145,15 @@ export const NlpEntityVarForm: FC> = ({ message: t("message.weight_positive_number_error"), }, validate: (value) => - value && value! > 0 + value && value > 0 ? true : t("message.weight_positive_number_error"), })} type="number" inputProps={{ - min: 0.01, + min: 0, step: 0.01, inputMode: "numeric", - pattern: "[1-9][0-9]*", }} error={!!errors.weight} helperText={errors.weight?.message} From 4f8ee27dbace04a9839a64ae79357e4a901a060b Mon Sep 17 00:00:00 2001 From: MohamedAliBouhaouala Date: Tue, 6 May 2025 19:46:31 +0100 Subject: [PATCH 06/15] fix: apply feedback --- api/docs/nlp/README.md | 102 ------------------ api/src/chat/services/block.service.ts | 4 +- .../nlp/services/nlp-entity.service.spec.ts | 3 +- api/src/nlp/services/nlp-entity.service.ts | 5 +- 4 files changed, 6 insertions(+), 108 deletions(-) delete mode 100644 api/docs/nlp/README.md diff --git a/api/docs/nlp/README.md b/api/docs/nlp/README.md deleted file mode 100644 index cc7daa2e..00000000 --- a/api/docs/nlp/README.md +++ /dev/null @@ -1,102 +0,0 @@ -# NLP Block Scoring -## Purpose - -**NLP Block Scoring** is a mechanism used to select the most relevant response block based on: - -- Matching patterns between user input and block definitions -- Configurable weights assigned to each entity type -- Confidence values provided by the NLU engine for detected entities - -It enables more intelligent and context-aware block selection in conversational flows. - -## Core Use Cases -### Standard Matching - -A user input contains entities that directly match a block’s patterns. -```ts -Example: Input: intent = enquiry & subject = claim -Block A: Patterns: intent: enquiry & subject: claim -Block A will be selected. -``` - -### High Confidence, Partial Match - -A block may match only some patterns but have high-confidence input on those matched ones, making it a better candidate than others with full matches but low-confidence entities. -**Note: Confidence is multiplied by a pre-defined weight for each entity type.** - -```ts -Example: -Input: intent = issue (confidence: 0.92) & subject = claim (confidence: 0.65) -Block A: Pattern: intent: issue -Block B: Pattern: subject: claim -➤ Block A gets a high score based on confidence × weight (assuming both weights are equal to 1). -``` - -### Multiple Blocks with Similar Patterns - -```ts -Input: intent = issue & subject = insurance -Block A: intent = enquiry & subject = insurance -Block B: subject = insurance -➤ Block B is selected — Block A mismatches on intent. -``` - -### Exclusion Due to Extra Patterns - -If a block contains patterns that require entities not present in the user input, the block is excluded from scoring altogether. No penalties are applied — the block simply isn't considered a valid candidate. - -```ts -Input: intent = issue & subject = insurance -Block A: intent = enquiry & subject = insurance & location = office -Block B: subject = insurance & time = morning -➤ Neither block is selected due to unmatched required patterns (`location`, `time`) -``` - -### Tie-Breaking with Penalty Factors - -When multiple blocks receive similar scores, penalty factors can help break the tie — especially in cases where patterns are less specific (e.g., using `Any` as a value). - -```ts -Input: intent = enquiry & subject = insurance - -Block A: intent = enquiry & subject = Any -Block B: intent = enquiry & subject = insurance -Block C: subject = insurance - -Scoring Summary: -- Block A matches both patterns, but subject = Any is considered less specific. -- Block B has a redundant but fully specific match. -- Block C matches only one pattern. - -➤ Block A and Block B have similar raw scores. -➤ A penalty factor is applied to Block A due to its use of Any, reducing its final score. -➤ Block B is selected. -``` - -## How Scoring Works -### Matching and Confidence - -For each entity in the block's pattern: -- If the entity `matches` an entity in the user input: - - the score is increased by: `confidence × weight` - - `Confidence` is a value between 0 and 1, returned by the NLU engine. - - `Weight` (default value is `1`) is a configured importance factor for that specific entity type. -- If the match is a wildcard (i.e., the block accepts any value): - - A **penalty factor** is applied to slightly reduce its contribution: - ``confidence × weight × penaltyFactor``. This encourages more specific matches when available. - -### Scoring Formula Summary - -For each matched entity: - -```ts -score += confidence × weight × [optional penalty factor if wildcard] -``` - -The total block score is the sum of all matched patterns in that block. - -### Penalty Factor - -The **penalty factor** is a global multiplier (typically less than `1`, e.g., `0.8`) applied when the match type is less specific — such as wildcard or loose entity type matches. It allows the system to: -- Break ties in favor of more precise blocks -- Discourage overly generic blocks from being selected when better matches are available diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index 076c6a07..75373f1f 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -305,7 +305,7 @@ export class BlockService extends BaseService< return undefined; } - const nlpPatterns = block.patterns?.filter((p) => { + const nlpPatterns = block.patterns.filter((p) => { return Array.isArray(p); }) as NlpPattern[][]; // No nlp patterns found @@ -313,7 +313,7 @@ export class BlockService extends BaseService< return undefined; } - // Find NLP pattern match based on best guessed entities + // Filter NLP patterns match based on best guessed entities return nlpPatterns.filter((entities: NlpPattern[]) => { return entities.every((ev: NlpPattern) => { if (ev.match === 'value') { diff --git a/api/src/nlp/services/nlp-entity.service.spec.ts b/api/src/nlp/services/nlp-entity.service.spec.ts index d90e6ff9..5cc877ff 100644 --- a/api/src/nlp/services/nlp-entity.service.spec.ts +++ b/api/src/nlp/services/nlp-entity.service.spec.ts @@ -9,6 +9,7 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { MongooseModule } from '@nestjs/mongoose'; +import { NOT_FOUND_ID } from '@/utils/constants/mock'; import { nlpEntityFixtures } from '@/utils/test/fixtures/nlpentity'; import { installNlpValueFixtures } from '@/utils/test/fixtures/nlpvalue'; import { getPageQuery } from '@/utils/test/pagination'; @@ -152,7 +153,7 @@ describe('nlpEntityService', () => { }); it('should handle updating weight of non-existent entity', async () => { - const nonExistentId = '507f1f77bcf86cd799439011'; // Example MongoDB ObjectId + const nonExistentId = NOT_FOUND_ID; try { await nlpEntityService.updateWeight(nonExistentId, 5); diff --git a/api/src/nlp/services/nlp-entity.service.ts b/api/src/nlp/services/nlp-entity.service.ts index c92b7888..2704dabd 100644 --- a/api/src/nlp/services/nlp-entity.service.ts +++ b/api/src/nlp/services/nlp-entity.service.ts @@ -158,12 +158,11 @@ export class NlpEntityService extends BaseService< /** * Retrieves NLP entity lookup information for the given list of entity names. * - * This method queries the database for lookups that match any of the provided - * entity names, transforms the result into a map structure where each key is + * This method queries the database for nlp entities, + * transforms the result into a map structure where each key is * the entity name and each value contains metadata (id, weight, and list of values), * and caches the result using the configured cache key. * - * @param entityNames - Array of entity names to retrieve lookup data for. * @returns A Promise that resolves to a map of entity name to its corresponding lookup metadata. */ @Cacheable(NLP_MAP_CACHE_KEY) From c7189758a9b523b524fbd3b6133191b075de596c Mon Sep 17 00:00:00 2001 From: MohamedAliBouhaouala Date: Wed, 7 May 2025 11:26:22 +0100 Subject: [PATCH 07/15] fix: correct entity data type --- api/src/chat/services/block.service.ts | 9 ++-- api/src/nlp/schemas/types.ts | 10 +--- api/src/utils/test/mocks/nlp.ts | 65 ++++++++++++++++++++++++-- 3 files changed, 67 insertions(+), 17 deletions(-) diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index 75373f1f..3c1308a0 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -16,7 +16,7 @@ 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'; -import { NlpCacheMapValues } from '@/nlp/schemas/types'; +import { NlpEntityFull } from '@/nlp/schemas/nlp-entity.schema'; import { NlpEntityService } from '@/nlp/services/nlp-entity.service'; import { PluginService } from '@/plugins/plugins.service'; import { PluginType } from '@/plugins/types'; @@ -403,7 +403,6 @@ export class BlockService extends BaseService< const matchedEntity: NLU.ParseEntity | undefined = nlp.entities.find( (e) => this.matchesEntityData(e, pattern, entityData!), ); - return this.computePatternScore( matchedEntity, pattern, @@ -434,11 +433,11 @@ export class BlockService extends BaseService< private matchesEntityData( e: NLU.ParseEntity, pattern: NlpPattern, - entityData: NlpCacheMapValues, + entityData: NlpEntityFull, ): boolean { return ( e.entity === pattern.entity && - entityData?.values.some((v) => v === e.value) && + entityData.values?.some((v) => v.value === e.value) && (pattern.match !== 'value' || e.value === pattern.value) ); } @@ -455,7 +454,7 @@ export class BlockService extends BaseService< private computePatternScore( entity: NLU.ParseEntity | undefined, pattern: NlpPattern, - entityData: NlpCacheMapValues, + entityData: NlpEntityFull, nlpPenaltyFactor: number, ): number { if (!entity || !entity.confidence) return 0; diff --git a/api/src/nlp/schemas/types.ts b/api/src/nlp/schemas/types.ts index 96b7dae4..6e87dee3 100644 --- a/api/src/nlp/schemas/types.ts +++ b/api/src/nlp/schemas/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 { NlpEntityStub } from './nlp-entity.schema'; +import { NlpEntityFull, NlpEntityStub } from './nlp-entity.schema'; import { NlpValueStub } from './nlp-value.schema'; export interface NlpSampleEntityValue { @@ -26,10 +26,4 @@ export enum NlpSampleState { inbox = 'inbox', } -export type NlpCacheMap = Map; - -export type NlpCacheMapValues = { - id: string; - weight: number; - values: string[]; -}; +export type NlpCacheMap = Map; diff --git a/api/src/utils/test/mocks/nlp.ts b/api/src/utils/test/mocks/nlp.ts index a88b3bbd..b5a08c02 100644 --- a/api/src/utils/test/mocks/nlp.ts +++ b/api/src/utils/test/mocks/nlp.ts @@ -63,17 +63,74 @@ export const mockNlpCacheMap: NlpCacheMap = new Map([ [ 'intent', { - id: '67e3e41eff551ca5be70559c', + id: '1', weight: 1, - values: ['greeting', 'affirmation'], + name: 'intent', + values: [ + { + id: '11', + value: 'greeting', + createdAt: new Date(), + updatedAt: new Date(), + doc: '', + entity: '1', + builtin: false, + metadata: {}, + expressions: [], + }, + { + id: '12', + value: 'affirmation', + createdAt: new Date(), + updatedAt: new Date(), + doc: '', + entity: '1', + builtin: false, + metadata: {}, + expressions: [], + }, + ], + lookups: ['trait'], + builtin: false, + createdAt: new Date(), + updatedAt: new Date(), }, ], [ 'firstname', + { - id: '67e3e41eff551ca5be70559d', + id: '2', weight: 1, - values: ['jhon', 'doe'], + name: 'firstname', + values: [ + { + id: '21', + value: 'jhon', + createdAt: new Date(), + updatedAt: new Date(), + doc: '', + entity: '2', + builtin: false, + metadata: {}, + expressions: [], + }, + { + id: '22', + value: 'doe', + createdAt: new Date(), + updatedAt: new Date(), + doc: '', + entity: '2', + builtin: false, + metadata: {}, + expressions: [], + }, + ], + lookups: ['trait'], + builtin: false, + createdAt: new Date(), + updatedAt: new Date(), }, ], ]); From 9642e823d045bcffd5aebf4c8b83df34c2bdc916 Mon Sep 17 00:00:00 2001 From: MohamedAliBouhaouala Date: Wed, 7 May 2025 17:47:25 +0100 Subject: [PATCH 08/15] feat: use mongo test db for unit tests --- api/src/chat/services/block.service.spec.ts | 42 ++++------ .../controllers/nlp-entity.controller.spec.ts | 8 +- .../nlp-entity.repository.spec.ts | 2 +- .../nlp/services/nlp-entity.service.spec.ts | 72 ++++++++--------- .../nlp/services/nlp-value.service.spec.ts | 6 +- api/src/utils/test/fixtures/nlpentity.ts | 9 ++- api/src/utils/test/fixtures/nlpvalue.ts | 7 ++ api/src/utils/test/mocks/nlp.ts | 77 ------------------- 8 files changed, 71 insertions(+), 152 deletions(-) diff --git a/api/src/chat/services/block.service.spec.ts b/api/src/chat/services/block.service.spec.ts index 6fb810f3..5b9b2afd 100644 --- a/api/src/chat/services/block.service.spec.ts +++ b/api/src/chat/services/block.service.spec.ts @@ -46,6 +46,7 @@ import { installBlockFixtures, } from '@/utils/test/fixtures/block'; import { installContentFixtures } from '@/utils/test/fixtures/content'; +import { installNlpValueFixtures } from '@/utils/test/fixtures/nlpvalue'; import { blockEmpty, blockGetStarted, @@ -64,7 +65,6 @@ import { subscriberContextBlankInstance, } from '@/utils/test/mocks/conversation'; import { - mockNlpCacheMap, mockNlpEntitiesSetOne, nlpEntitiesGreeting, } from '@/utils/test/mocks/nlp'; @@ -102,6 +102,7 @@ describe('BlockService', () => { rootMongooseTestModule(async () => { await installContentFixtures(); await installBlockFixtures(); + await installNlpValueFixtures(); }), MongooseModule.forFeature([ BlockModel, @@ -357,10 +358,7 @@ describe('BlockService', () => { describe('matchBestNLP', () => { it('should return the block with the highest NLP score', async () => { - jest - .spyOn(nlpEntityService, 'getNlpMap') - .mockResolvedValue(mockNlpCacheMap); - const blocks = [mockNlpBlock, blockGetStarted]; // You can add more blocks with different patterns and scores + const blocks = [mockNlpBlock, blockGetStarted]; const nlp = mockNlpEntitiesSetOne; // Spy on calculateBlockScore to check if it's called const calculateBlockScoreSpy = jest.spyOn( @@ -379,10 +377,7 @@ describe('BlockService', () => { }); it('should return the block with the highest NLP score applying penalties', async () => { - jest - .spyOn(nlpEntityService, 'getNlpMap') - .mockResolvedValue(mockNlpCacheMap); - const blocks = [mockNlpBlock, mockModifiedNlpBlock]; // You can add more blocks with different patterns and scores + const blocks = [mockNlpBlock, mockModifiedNlpBlock]; const nlp = mockNlpEntitiesSetOne; // Spy on calculateBlockScore to check if it's called const calculateBlockScoreSpy = jest.spyOn( @@ -401,9 +396,6 @@ describe('BlockService', () => { }); it('another case where it should return the block with the highest NLP score applying penalties', async () => { - jest - .spyOn(nlpEntityService, 'getNlpMap') - .mockResolvedValue(mockNlpCacheMap); const blocks = [mockModifiedNlpBlockOne, mockModifiedNlpBlockTwo]; // You can add more blocks with different patterns and scores const nlp = mockNlpEntitiesSetOne; // Spy on calculateBlockScore to check if it's called @@ -423,10 +415,7 @@ describe('BlockService', () => { }); it('should return undefined if no blocks match or the list is empty', async () => { - jest - .spyOn(nlpEntityService, 'getNlpMap') - .mockResolvedValue(mockNlpCacheMap); - const blocks: BlockFull[] = []; // Empty block array + const blocks: BlockFull[] = []; const nlp = mockNlpEntitiesSetOne; const bestBlock = await blockService.matchBestNLP(blocks, nlp); @@ -438,9 +427,7 @@ describe('BlockService', () => { describe('calculateBlockScore', () => { it('should calculate the correct NLP score for a block', async () => { - jest - .spyOn(nlpEntityService, 'getNlpMap') - .mockResolvedValue(mockNlpCacheMap); + const getNlpCacheMapSpy = jest.spyOn(nlpEntityService, 'getNlpMap'); const score = await blockService.calculateBlockScore( mockNlpPatternsSetOne, mockNlpEntitiesSetOne, @@ -449,16 +436,15 @@ describe('BlockService', () => { mockNlpPatternsSetTwo, mockNlpEntitiesSetOne, ); - + expect(getNlpCacheMapSpy).toHaveBeenCalledTimes(2); + getNlpCacheMapSpy.mockRestore(); expect(score).toBeGreaterThan(0); expect(score2).toBe(0); expect(score).toBeGreaterThan(score2); }); it('should calculate the correct NLP score for a block and apply penalties ', async () => { - jest - .spyOn(nlpEntityService, 'getNlpMap') - .mockResolvedValue(mockNlpCacheMap); + const getNlpCacheMapSpy = jest.spyOn(nlpEntityService, 'getNlpMap'); const score = await blockService.calculateBlockScore( mockNlpPatternsSetOne, mockNlpEntitiesSetOne, @@ -468,19 +454,25 @@ describe('BlockService', () => { mockNlpEntitiesSetOne, ); + expect(getNlpCacheMapSpy).toHaveBeenCalledTimes(2); + getNlpCacheMapSpy.mockRestore(); + expect(score).toBeGreaterThan(0); expect(score2).toBeGreaterThan(0); expect(score).toBeGreaterThan(score2); }); it('should return 0 if no matching entities are found', async () => { - jest.spyOn(nlpEntityService, 'getNlpMap').mockResolvedValue(new Map()); + const getNlpCacheMapSpy = jest.spyOn(nlpEntityService, 'getNlpMap'); + const score = await blockService.calculateBlockScore( mockNlpPatternsSetTwo, mockNlpEntitiesSetOne, ); + expect(getNlpCacheMapSpy).toHaveBeenCalledTimes(1); + getNlpCacheMapSpy.mockRestore(); - expect(score).toBe(0); // No matching entity, so score should be 0 + expect(score).toBe(0); // No matching entity }); }); diff --git a/api/src/nlp/controllers/nlp-entity.controller.spec.ts b/api/src/nlp/controllers/nlp-entity.controller.spec.ts index fd70a91b..484f41c8 100644 --- a/api/src/nlp/controllers/nlp-entity.controller.spec.ts +++ b/api/src/nlp/controllers/nlp-entity.controller.spec.ts @@ -201,18 +201,18 @@ describe('NlpEntityController', () => { describe('findOne', () => { it('should find a nlp entity', async () => { const firstNameEntity = await nlpEntityService.findOne({ - name: 'first_name', + name: 'firstname', }); const result = await nlpEntityController.findOne(firstNameEntity!.id, []); expect(result).toEqualPayload( - nlpEntityFixtures.find(({ name }) => name === 'first_name')!, + nlpEntityFixtures.find(({ name }) => name === 'firstname')!, ); }); it('should find a nlp entity, and populate its values', async () => { const firstNameEntity = await nlpEntityService.findOne({ - name: 'first_name', + name: 'firstname', }); const firstNameValues = await nlpValueService.findOne({ value: 'jhon' }); const firstNameWithValues: NlpEntityFull = { @@ -242,7 +242,7 @@ describe('NlpEntityController', () => { describe('updateOne', () => { it('should update a nlp entity', async () => { const firstNameEntity = await nlpEntityService.findOne({ - name: 'first_name', + name: 'firstname', }); const updatedNlpEntity: NlpEntityCreateDto = { name: 'updated', diff --git a/api/src/nlp/repositories/nlp-entity.repository.spec.ts b/api/src/nlp/repositories/nlp-entity.repository.spec.ts index 7f9bccd3..fd8f2262 100644 --- a/api/src/nlp/repositories/nlp-entity.repository.spec.ts +++ b/api/src/nlp/repositories/nlp-entity.repository.spec.ts @@ -51,7 +51,7 @@ describe('NlpEntityRepository', () => { NlpValueRepository, ]); firstNameNlpEntity = await nlpEntityRepository.findOne({ - name: 'first_name', + name: 'firstname', }); }); diff --git a/api/src/nlp/services/nlp-entity.service.spec.ts b/api/src/nlp/services/nlp-entity.service.spec.ts index 5cc877ff..eae5b51f 100644 --- a/api/src/nlp/services/nlp-entity.service.spec.ts +++ b/api/src/nlp/services/nlp-entity.service.spec.ts @@ -22,11 +22,7 @@ import { buildTestingMocks } from '@/utils/test/utils'; import { NlpEntityRepository } from '../repositories/nlp-entity.repository'; import { NlpSampleEntityRepository } from '../repositories/nlp-sample-entity.repository'; import { NlpValueRepository } from '../repositories/nlp-value.repository'; -import { - NlpEntity, - NlpEntityFull, - NlpEntityModel, -} from '../schemas/nlp-entity.schema'; +import { NlpEntity, NlpEntityModel } from '../schemas/nlp-entity.schema'; import { NlpSampleEntityModel } from '../schemas/nlp-sample-entity.schema'; import { NlpValueModel } from '../schemas/nlp-value.schema'; @@ -91,7 +87,7 @@ describe('nlpEntityService', () => { describe('findOneAndPopulate', () => { it('should return a nlp entity with populate', async () => { const firstNameNlpEntity = await nlpEntityRepository.findOne({ - name: 'first_name', + name: 'firstname', }); const result = await nlpEntityService.findOneAndPopulate( firstNameNlpEntity!.id, @@ -112,7 +108,7 @@ describe('nlpEntityService', () => { it('should return all nlp entities with populate', async () => { const pageQuery = getPageQuery({ sort: ['name', 'desc'] }); const firstNameNlpEntity = await nlpEntityRepository.findOne({ - name: 'first_name', + name: 'firstname', }); const result = await nlpEntityService.findPageAndPopulate( { _id: firstNameNlpEntity!.id }, @@ -221,45 +217,37 @@ describe('nlpEntityService', () => { }); describe('getNlpMap', () => { it('should return a NlpCacheMap with the correct structure', async () => { - // Arrange - const firstMockValues = { - id: '1', - weight: 1, - }; - const firstMockEntity = { - name: 'intent', - ...firstMockValues, - values: [{ value: 'buy' }, { value: 'sell' }], - } as unknown as Partial; - const secondMockValues = { - id: '2', - weight: 5, - }; - const secondMockEntity = { - name: 'subject', - ...secondMockValues, - values: [{ value: 'product' }], - } as unknown as Partial; - const mockEntities = [firstMockEntity, secondMockEntity]; - - // Mock findAndPopulate - jest - .spyOn(nlpEntityService, 'findAllAndPopulate') - .mockResolvedValue(mockEntities as unknown as NlpEntityFull[]); - // Act const result = await nlpEntityService.getNlpMap(); expect(result).toBeInstanceOf(Map); - expect(result.size).toBe(2); - expect(result.get('intent')).toEqual({ - name: 'intent', - ...firstMockEntity, - }); - expect(result.get('subject')).toEqual({ - name: 'subject', - ...secondMockEntity, - }); + expect(result.get('firstname')).toEqual( + expect.objectContaining({ + name: 'firstname', + lookups: ['keywords'], + doc: '', + builtin: false, + weight: 1, + values: [ + expect.objectContaining({ + value: 'jhon', + expressions: ['john', 'joohn', 'jhonny'], + builtin: true, + doc: '', + }), + ], + }), + ); + expect(result.get('subject')).toEqual( + expect.objectContaining({ + name: 'subject', + lookups: ['trait'], + doc: '', + builtin: false, + weight: 1, + values: [], + }), + ); }); }); }); diff --git a/api/src/nlp/services/nlp-value.service.spec.ts b/api/src/nlp/services/nlp-value.service.spec.ts index eacae606..f55b5a68 100644 --- a/api/src/nlp/services/nlp-value.service.spec.ts +++ b/api/src/nlp/services/nlp-value.service.spec.ts @@ -63,6 +63,8 @@ describe('NlpValueService', () => { provide: CACHE_MANAGER, useValue: { del: jest.fn(), + set: jest.fn(), + get: jest.fn(), }, }, ], @@ -132,7 +134,7 @@ describe('NlpValueService', () => { 'Hello do you see me', [ { entity: 'intent', value: 'greeting' }, - { entity: 'first_name', value: 'jhon' }, + { entity: 'firstname', value: 'jhon' }, ], storedEntities, ); @@ -140,7 +142,7 @@ describe('NlpValueService', () => { name: 'intent', }); const firstNameEntity = await nlpEntityRepository.findOne({ - name: 'first_name', + name: 'firstname', }); const greetingValue = await nlpValueRepository.findOne({ value: 'greeting', diff --git a/api/src/utils/test/fixtures/nlpentity.ts b/api/src/utils/test/fixtures/nlpentity.ts index deb44e97..78f29236 100644 --- a/api/src/utils/test/fixtures/nlpentity.ts +++ b/api/src/utils/test/fixtures/nlpentity.ts @@ -20,7 +20,7 @@ export const nlpEntityFixtures: NlpEntityCreateDto[] = [ weight: 1, }, { - name: 'first_name', + name: 'firstname', lookups: ['keywords'], doc: '', builtin: false, @@ -33,6 +33,13 @@ export const nlpEntityFixtures: NlpEntityCreateDto[] = [ builtin: true, weight: 1, }, + { + name: 'subject', + lookups: ['trait'], + doc: '', + builtin: false, + weight: 1, + }, ]; export const installNlpEntityFixtures = async () => { diff --git a/api/src/utils/test/fixtures/nlpvalue.ts b/api/src/utils/test/fixtures/nlpvalue.ts index fe8714b6..cf99f63c 100644 --- a/api/src/utils/test/fixtures/nlpvalue.ts +++ b/api/src/utils/test/fixtures/nlpvalue.ts @@ -49,6 +49,13 @@ export const nlpValueFixtures: NlpValueCreateDto[] = [ builtin: true, doc: '', }, + { + entity: '0', + value: 'affirmation', + expressions: ['yes', 'oui', 'yeah'], + builtin: false, + doc: '', + }, ]; export const installNlpValueFixtures = async () => { diff --git a/api/src/utils/test/mocks/nlp.ts b/api/src/utils/test/mocks/nlp.ts index b5a08c02..04c1742d 100644 --- a/api/src/utils/test/mocks/nlp.ts +++ b/api/src/utils/test/mocks/nlp.ts @@ -7,7 +7,6 @@ */ import { NLU } from '@/helper/types'; -import { NlpCacheMap } from '@/nlp/schemas/types'; export const nlpEntitiesGreeting: NLU.ParseEntities = { entities: [ @@ -58,79 +57,3 @@ export const mockNlpEntitiesSetTwo: NLU.ParseEntities = { }, ], }; - -export const mockNlpCacheMap: NlpCacheMap = new Map([ - [ - 'intent', - { - id: '1', - weight: 1, - name: 'intent', - values: [ - { - id: '11', - value: 'greeting', - createdAt: new Date(), - updatedAt: new Date(), - doc: '', - entity: '1', - builtin: false, - metadata: {}, - expressions: [], - }, - { - id: '12', - value: 'affirmation', - createdAt: new Date(), - updatedAt: new Date(), - doc: '', - entity: '1', - builtin: false, - metadata: {}, - expressions: [], - }, - ], - lookups: ['trait'], - builtin: false, - createdAt: new Date(), - updatedAt: new Date(), - }, - ], - [ - 'firstname', - - { - id: '2', - weight: 1, - name: 'firstname', - values: [ - { - id: '21', - value: 'jhon', - createdAt: new Date(), - updatedAt: new Date(), - doc: '', - entity: '2', - builtin: false, - metadata: {}, - expressions: [], - }, - { - id: '22', - value: 'doe', - createdAt: new Date(), - updatedAt: new Date(), - doc: '', - entity: '2', - builtin: false, - metadata: {}, - expressions: [], - }, - ], - lookups: ['trait'], - builtin: false, - createdAt: new Date(), - updatedAt: new Date(), - }, - ], -]); From 41de535a7c65b785ca954fb61fdc9f7344105902 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Mon, 12 May 2025 14:13:09 +0100 Subject: [PATCH 09/15] fix: enhance implementation --- .../chat/controllers/block.controller.spec.ts | 21 +- api/src/chat/services/block.service.spec.ts | 249 ++++++++++-------- api/src/chat/services/block.service.ts | 218 ++++++++------- api/src/chat/services/bot.service.spec.ts | 18 +- api/src/chat/services/chat.service.ts | 47 +++- api/src/helper/types.ts | 8 + api/src/i18n/services/language.service.ts | 2 +- .../nlp-entity.repository.spec.ts | 4 +- .../repositories/nlp-value.repository.spec.ts | 25 +- .../nlp/services/nlp-entity.service.spec.ts | 39 ++- .../nlp/services/nlp-sample.service.spec.ts | 15 +- .../nlp/services/nlp-value.service.spec.ts | 24 +- api/src/nlp/services/nlp.service.spec.ts | 134 ++++++++++ api/src/nlp/services/nlp.service.ts | 31 +++ api/src/utils/test/fixtures/nlpentity.ts | 4 +- api/src/utils/test/fixtures/nlpvalue.ts | 24 +- api/src/utils/test/mocks/block.ts | 73 ++--- api/src/utils/test/mocks/nlp.ts | 28 +- 18 files changed, 623 insertions(+), 341 deletions(-) create mode 100644 api/src/nlp/services/nlp.service.spec.ts diff --git a/api/src/chat/controllers/block.controller.spec.ts b/api/src/chat/controllers/block.controller.spec.ts index 16e1754b..05a952dd 100644 --- a/api/src/chat/controllers/block.controller.spec.ts +++ b/api/src/chat/controllers/block.controller.spec.ts @@ -20,14 +20,7 @@ import { LanguageRepository } from '@/i18n/repositories/language.repository'; import { LanguageModel } from '@/i18n/schemas/language.schema'; import { I18nService } from '@/i18n/services/i18n.service'; import { LanguageService } from '@/i18n/services/language.service'; -import { NlpEntityRepository } from '@/nlp/repositories/nlp-entity.repository'; -import { NlpSampleEntityRepository } from '@/nlp/repositories/nlp-sample-entity.repository'; -import { NlpValueRepository } from '@/nlp/repositories/nlp-value.repository'; -import { NlpEntityModel } from '@/nlp/schemas/nlp-entity.schema'; -import { NlpSampleEntityModel } from '@/nlp/schemas/nlp-sample-entity.schema'; -import { NlpValueModel } from '@/nlp/schemas/nlp-value.schema'; -import { NlpEntityService } from '@/nlp/services/nlp-entity.service'; -import { NlpValueService } from '@/nlp/services/nlp-value.service'; +import { NlpService } from '@/nlp/services/nlp.service'; import { PluginService } from '@/plugins/plugins.service'; import { SettingService } from '@/setting/services/setting.service'; import { InvitationRepository } from '@/user/repositories/invitation.repository'; @@ -101,9 +94,6 @@ describe('BlockController', () => { RoleModel, PermissionModel, LanguageModel, - NlpEntityModel, - NlpSampleEntityModel, - NlpValueModel, ]), ], providers: [ @@ -127,11 +117,6 @@ describe('BlockController', () => { PermissionService, LanguageService, PluginService, - NlpEntityService, - NlpEntityRepository, - NlpSampleEntityRepository, - NlpValueRepository, - NlpValueService, { provide: I18nService, useValue: { @@ -155,6 +140,10 @@ describe('BlockController', () => { set: jest.fn(), }, }, + { + provide: NlpService, + useValue: {}, + }, ], }); [blockController, blockService, categoryService] = await getMocks([ diff --git a/api/src/chat/services/block.service.spec.ts b/api/src/chat/services/block.service.spec.ts index 5b9b2afd..5cbbc1c3 100644 --- a/api/src/chat/services/block.service.spec.ts +++ b/api/src/chat/services/block.service.spec.ts @@ -27,18 +27,24 @@ import WebChannelHandler from '@/extensions/channels/web/index.channel'; import { WEB_CHANNEL_NAME } from '@/extensions/channels/web/settings'; import { Web } from '@/extensions/channels/web/types'; import WebEventWrapper from '@/extensions/channels/web/wrapper'; +import { HelperService } from '@/helper/helper.service'; import { LanguageRepository } from '@/i18n/repositories/language.repository'; import { LanguageModel } from '@/i18n/schemas/language.schema'; import { I18nService } from '@/i18n/services/i18n.service'; import { LanguageService } from '@/i18n/services/language.service'; import { NlpEntityRepository } from '@/nlp/repositories/nlp-entity.repository'; import { NlpSampleEntityRepository } from '@/nlp/repositories/nlp-sample-entity.repository'; +import { NlpSampleRepository } from '@/nlp/repositories/nlp-sample.repository'; import { NlpValueRepository } from '@/nlp/repositories/nlp-value.repository'; import { NlpEntityModel } from '@/nlp/schemas/nlp-entity.schema'; import { NlpSampleEntityModel } from '@/nlp/schemas/nlp-sample-entity.schema'; +import { NlpSampleModel } from '@/nlp/schemas/nlp-sample.schema'; import { NlpValueModel } from '@/nlp/schemas/nlp-value.schema'; import { NlpEntityService } from '@/nlp/services/nlp-entity.service'; +import { NlpSampleEntityService } from '@/nlp/services/nlp-sample-entity.service'; +import { NlpSampleService } from '@/nlp/services/nlp-sample.service'; import { NlpValueService } from '@/nlp/services/nlp-value.service'; +import { NlpService } from '@/nlp/services/nlp.service'; import { PluginService } from '@/plugins/plugins.service'; import { SettingService } from '@/setting/services/setting.service'; import { @@ -52,21 +58,19 @@ import { blockGetStarted, blockProductListMock, blocks, - mockModifiedNlpBlock, - mockModifiedNlpBlockOne, - mockModifiedNlpBlockTwo, - mockNlpBlock, - mockNlpPatternsSetOne, - mockNlpPatternsSetThree, - mockNlpPatternsSetTwo, + mockNlpAffirmationPatterns, + mockNlpGreetingAnyNamePatterns, + mockNlpGreetingNamePatterns, + mockNlpGreetingPatterns, + mockNlpGreetingWrongNamePatterns, } from '@/utils/test/mocks/block'; import { contextBlankInstance, subscriberContextBlankInstance, } from '@/utils/test/mocks/conversation'; import { - mockNlpEntitiesSetOne, - nlpEntitiesGreeting, + mockNlpGreetingFullNameEntities, + mockNlpGreetingNameEntities, } from '@/utils/test/mocks/nlp'; import { closeInMongodConnection, @@ -94,7 +98,6 @@ describe('BlockService', () => { let hasPreviousBlocks: Block; let contentService: ContentService; let contentTypeService: ContentTypeService; - let nlpEntityService: NlpEntityService; beforeAll(async () => { const { getMocks } = await buildTestingMocks({ @@ -115,6 +118,7 @@ describe('BlockService', () => { NlpEntityModel, NlpSampleEntityModel, NlpValueModel, + NlpSampleModel, ]), ], providers: [ @@ -132,12 +136,14 @@ describe('BlockService', () => { LanguageService, NlpEntityRepository, NlpValueRepository, + NlpSampleRepository, NlpSampleEntityRepository, NlpEntityService, - { - provide: NlpValueService, - useValue: {}, - }, + NlpValueService, + NlpSampleService, + NlpSampleEntityService, + NlpService, + HelperService, { provide: PluginService, useValue: {}, @@ -177,14 +183,12 @@ describe('BlockService', () => { contentTypeService, categoryRepository, blockRepository, - nlpEntityService, ] = await getMocks([ BlockService, ContentService, ContentTypeService, CategoryRepository, BlockRepository, - NlpEntityService, ]); category = (await categoryRepository.findOne({ label: 'default' }))!; hasPreviousBlocks = (await blockRepository.findOne({ @@ -302,7 +306,7 @@ describe('BlockService', () => { it('should match block with nlp', async () => { webEventGreeting.setSender(subscriberWithLabels); - webEventGreeting.setNLP(nlpEntitiesGreeting); + webEventGreeting.setNLP(mockNlpGreetingFullNameEntities); const result = await blockService.match(blocks, webEventGreeting); expect(result).toEqual(blockGetStarted); }); @@ -310,19 +314,28 @@ describe('BlockService', () => { describe('matchNLP', () => { it('should return undefined for match nlp against a block with no patterns', () => { - const result = blockService.matchNLP(nlpEntitiesGreeting, blockEmpty); - expect(result).toEqual(undefined); + const result = blockService.getMatchingNluPatterns( + mockNlpGreetingFullNameEntities, + blockEmpty, + ); + expect(result).toEqual([]); }); it('should return undefined for match nlp when no nlp entities are provided', () => { - const result = blockService.matchNLP({ entities: [] }, blockGetStarted); - expect(result).toEqual(undefined); + const result = blockService.getMatchingNluPatterns( + { entities: [] }, + blockGetStarted, + ); + expect(result).toEqual([]); }); it('should return match nlp patterns', () => { - const result = blockService.matchNLP( - nlpEntitiesGreeting, - blockGetStarted, + const result = blockService.getMatchingNluPatterns( + mockNlpGreetingFullNameEntities, + { + ...blockGetStarted, + patterns: [...blockGetStarted.patterns, mockNlpGreetingNamePatterns], + }, ); expect(result).toEqual([ [ @@ -333,146 +346,168 @@ describe('BlockService', () => { }, { entity: 'firstname', - match: 'entity', + match: 'value', + value: 'jhon', }, ], ]); }); it('should return empty array when it does not match nlp patterns', () => { - const result = blockService.matchNLP(nlpEntitiesGreeting, { - ...blockGetStarted, - patterns: [[{ entity: 'lastname', match: 'value', value: 'Belakhel' }]], - }); + const result = blockService.getMatchingNluPatterns( + mockNlpGreetingFullNameEntities, + { + ...blockGetStarted, + patterns: [ + [{ entity: 'lastname', match: 'value', value: 'Belakhel' }], + ], + }, + ); expect(result).toEqual([]); }); it('should return empty array when unknown nlp patterns', () => { - const result = blockService.matchNLP(nlpEntitiesGreeting, { - ...blockGetStarted, - patterns: [[{ entity: 'product', match: 'value', value: 'pizza' }]], - }); + const result = blockService.getMatchingNluPatterns( + mockNlpGreetingFullNameEntities, + { + ...blockGetStarted, + patterns: [[{ entity: 'product', match: 'value', value: 'pizza' }]], + }, + ); expect(result).toEqual([]); }); }); describe('matchBestNLP', () => { it('should return the block with the highest NLP score', async () => { - const blocks = [mockNlpBlock, blockGetStarted]; - const nlp = mockNlpEntitiesSetOne; + const mockExpectedBlock: BlockFull = { + ...blockGetStarted, + patterns: [...blockGetStarted.patterns, mockNlpGreetingNamePatterns], + }; + const blocks: BlockFull[] = [ + // no match + blockGetStarted, + // match + mockExpectedBlock, + // match + { + ...blockGetStarted, + patterns: [...blockGetStarted.patterns, mockNlpGreetingPatterns], + }, + // no match + { + ...blockGetStarted, + patterns: [ + ...blockGetStarted.patterns, + mockNlpGreetingWrongNamePatterns, + ], + }, + // no match + { + ...blockGetStarted, + patterns: [...blockGetStarted.patterns, mockNlpAffirmationPatterns], + }, + // no match + blockGetStarted, + ]; + // Spy on calculateBlockScore to check if it's called const calculateBlockScoreSpy = jest.spyOn( blockService, - 'calculateBlockScore', + 'calculateNluPatternMatchScore', + ); + const bestBlock = await blockService.matchBestNLP( + blocks, + mockNlpGreetingNameEntities, ); - const bestBlock = await blockService.matchBestNLP(blocks, nlp); // Ensure calculateBlockScore was called at least once for each block expect(calculateBlockScoreSpy).toHaveBeenCalledTimes(2); // Called for each block - // Restore the spy after the test - calculateBlockScoreSpy.mockRestore(); // Assert that the block with the highest NLP score is selected - expect(bestBlock).toEqual(mockNlpBlock); + expect(bestBlock).toEqual(mockExpectedBlock); }); it('should return the block with the highest NLP score applying penalties', async () => { - const blocks = [mockNlpBlock, mockModifiedNlpBlock]; - const nlp = mockNlpEntitiesSetOne; + const mockExpectedBlock: BlockFull = { + ...blockGetStarted, + patterns: [...blockGetStarted.patterns, mockNlpGreetingNamePatterns], + }; + const blocks: BlockFull[] = [ + // no match + blockGetStarted, + // match + mockExpectedBlock, + // match + { + ...blockGetStarted, + patterns: [...blockGetStarted.patterns, mockNlpGreetingPatterns], + }, + // match + { + ...blockGetStarted, + patterns: [ + ...blockGetStarted.patterns, + mockNlpGreetingAnyNamePatterns, + ], + }, + ]; + const nlp = mockNlpGreetingNameEntities; // Spy on calculateBlockScore to check if it's called const calculateBlockScoreSpy = jest.spyOn( blockService, - 'calculateBlockScore', - ); - const bestBlock = await blockService.matchBestNLP(blocks, nlp); - - // Ensure calculateBlockScore was called at least once for each block - expect(calculateBlockScoreSpy).toHaveBeenCalledTimes(2); // Called for each block - - // Restore the spy after the test - calculateBlockScoreSpy.mockRestore(); - // Assert that the block with the highest NLP score is selected - expect(bestBlock).toEqual(mockNlpBlock); - }); - - it('another case where it should return the block with the highest NLP score applying penalties', async () => { - const blocks = [mockModifiedNlpBlockOne, mockModifiedNlpBlockTwo]; // You can add more blocks with different patterns and scores - const nlp = mockNlpEntitiesSetOne; - // Spy on calculateBlockScore to check if it's called - const calculateBlockScoreSpy = jest.spyOn( - blockService, - 'calculateBlockScore', + 'calculateNluPatternMatchScore', ); const bestBlock = await blockService.matchBestNLP(blocks, nlp); // Ensure calculateBlockScore was called at least once for each block expect(calculateBlockScoreSpy).toHaveBeenCalledTimes(3); // Called for each block - // Restore the spy after the test - calculateBlockScoreSpy.mockRestore(); // Assert that the block with the highest NLP score is selected - expect(bestBlock).toEqual(mockModifiedNlpBlockTwo); + expect(bestBlock).toEqual(mockExpectedBlock); }); it('should return undefined if no blocks match or the list is empty', async () => { - const blocks: BlockFull[] = []; - const nlp = mockNlpEntitiesSetOne; + const blocks: BlockFull[] = [ + { + ...blockGetStarted, + patterns: [...blockGetStarted.patterns, mockNlpAffirmationPatterns], + }, + blockGetStarted, + ]; - const bestBlock = await blockService.matchBestNLP(blocks, nlp); + const bestBlock = await blockService.matchBestNLP( + blocks, + mockNlpGreetingNameEntities, + ); // Assert that undefined is returned when no blocks are available expect(bestBlock).toBeUndefined(); }); }); - describe('calculateBlockScore', () => { + describe('calculateNluPatternMatchScore', () => { it('should calculate the correct NLP score for a block', async () => { - const getNlpCacheMapSpy = jest.spyOn(nlpEntityService, 'getNlpMap'); - const score = await blockService.calculateBlockScore( - mockNlpPatternsSetOne, - mockNlpEntitiesSetOne, + const matchingScore = blockService.calculateNluPatternMatchScore( + mockNlpGreetingNamePatterns, + mockNlpGreetingNameEntities, ); - const score2 = await blockService.calculateBlockScore( - mockNlpPatternsSetTwo, - mockNlpEntitiesSetOne, - ); - expect(getNlpCacheMapSpy).toHaveBeenCalledTimes(2); - getNlpCacheMapSpy.mockRestore(); - expect(score).toBeGreaterThan(0); - expect(score2).toBe(0); - expect(score).toBeGreaterThan(score2); + + expect(matchingScore).toBeGreaterThan(0); }); it('should calculate the correct NLP score for a block and apply penalties ', async () => { - const getNlpCacheMapSpy = jest.spyOn(nlpEntityService, 'getNlpMap'); - const score = await blockService.calculateBlockScore( - mockNlpPatternsSetOne, - mockNlpEntitiesSetOne, - ); - const score2 = await blockService.calculateBlockScore( - mockNlpPatternsSetThree, - mockNlpEntitiesSetOne, + const scoreWithoutPenalty = blockService.calculateNluPatternMatchScore( + mockNlpGreetingNamePatterns, + mockNlpGreetingNameEntities, ); - expect(getNlpCacheMapSpy).toHaveBeenCalledTimes(2); - getNlpCacheMapSpy.mockRestore(); - - expect(score).toBeGreaterThan(0); - expect(score2).toBeGreaterThan(0); - expect(score).toBeGreaterThan(score2); - }); - - it('should return 0 if no matching entities are found', async () => { - const getNlpCacheMapSpy = jest.spyOn(nlpEntityService, 'getNlpMap'); - - const score = await blockService.calculateBlockScore( - mockNlpPatternsSetTwo, - mockNlpEntitiesSetOne, + const scoreWithPenalty = blockService.calculateNluPatternMatchScore( + mockNlpGreetingAnyNamePatterns, + mockNlpGreetingNameEntities, ); - expect(getNlpCacheMapSpy).toHaveBeenCalledTimes(1); - getNlpCacheMapSpy.mockRestore(); - expect(score).toBe(0); // No matching entity + expect(scoreWithoutPenalty).toBeGreaterThan(scoreWithPenalty); }); }); diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index 3c1308a0..f4753ea3 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -16,8 +16,7 @@ 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'; -import { NlpEntityFull } from '@/nlp/schemas/nlp-entity.schema'; -import { NlpEntityService } from '@/nlp/services/nlp-entity.service'; +import { NlpService } from '@/nlp/services/nlp.service'; import { PluginService } from '@/plugins/plugins.service'; import { PluginType } from '@/plugins/types'; import { SettingService } from '@/setting/services/setting.service'; @@ -27,7 +26,12 @@ import { getRandomElement } from '@/utils/helpers/safeRandom'; import { BlockDto } from '../dto/block.dto'; import { EnvelopeFactory } from '../helpers/envelope-factory'; import { BlockRepository } from '../repositories/block.repository'; -import { Block, BlockFull, BlockPopulate } from '../schemas/block.schema'; +import { + Block, + BlockFull, + BlockPopulate, + BlockStub, +} from '../schemas/block.schema'; import { Label } from '../schemas/label.schema'; import { Subscriber } from '../schemas/subscriber.schema'; import { Context } from '../schemas/types/context'; @@ -55,7 +59,7 @@ export class BlockService extends BaseService< private readonly pluginService: PluginService, protected readonly i18n: I18nService, protected readonly languageService: LanguageService, - protected readonly entityService: NlpEntityService, + protected readonly nlpService: NlpService, ) { super(repository); } @@ -164,18 +168,6 @@ export class BlockService extends BaseService< // Perform a text match (Text or Quick reply) const text = event.getText().trim(); - // Check & catch user language through NLP - const nlp = event.getNLP(); - if (nlp) { - const languages = await this.languageService.getLanguages(); - const lang = nlp.entities.find((e) => e.entity === 'language'); - if (lang && Object.keys(languages).indexOf(lang.value) !== -1) { - const profile = event.getSender(); - profile.language = lang.value; - event.setSender(profile); - } - } - // Perform a text pattern match block = filteredBlocks .filter((b) => { @@ -184,9 +176,12 @@ export class BlockService extends BaseService< .shift(); // Perform an NLP Match - + const nlp = event.getNLP(); if (!block && nlp) { - block = await this.matchBestNLP(filteredBlocks, nlp); + const scoredEntities = + await this.nlpService.computePredictionScore(nlp); + + block = await this.matchBestNLP(filteredBlocks, scoredEntities); } } @@ -289,28 +284,29 @@ export class BlockService extends BaseService< } /** - * Performs an NLP pattern match based on the best guessed entities and/or values + * Performs an NLU pattern match based on the predicted entities and/or values * * @param nlp - Parsed NLP entities * @param block - The block to test * - * @returns The NLP patterns that matches + * @returns The NLU patterns that matches the predicted entities */ - matchNLP( - nlp: NLU.ParseEntities, - block: Block | BlockFull, - ): NlpPattern[][] | undefined { + getMatchingNluPatterns( + nlp: E, + block: B, + ): NlpPattern[][] { // No nlp entities to check against if (nlp.entities.length === 0) { - return undefined; + return []; } const nlpPatterns = block.patterns.filter((p) => { return Array.isArray(p); }) as NlpPattern[][]; + // No nlp patterns found if (nlpPatterns.length === 0) { - return undefined; + return []; } // Filter NLP patterns match based on best guessed entities @@ -333,86 +329,96 @@ export class BlockService extends BaseService< } /** - * Matches the provided NLU parsed entities with patterns in a set of blocks and returns - * the block with the highest matching score. + * Finds and returns the block that best matches the given scored NLU entities. * - * For each block, it checks the patterns against the NLU parsed entities, calculates - * a score for each match, and selects the block with the highest score. + * This function evaluates each block by matching its NLP patterns against the provided + * `scoredEntities`, using `matchNLP` and `calculateNluPatternMatchScore` to compute + * a confidence score for each match. The block with the highest total pattern match score + * is returned. * - * @param {BlockFull[]} blocks - An array of BlockFull objects representing potential matches. - * @param {NLU.ParseEntities} nlp - The NLU parsed entities used for pattern matching. + * If no block yields a positive score, the function returns `undefined`. * - * @returns {Promise} - A promise that resolves to the BlockFull - * with the highest match score, or undefined if no matches are found. + * @param blocks - A list of blocks to evaluate, each potentially containing NLP patterns. + * @param scoredEntities - The scored NLU entities to use for pattern matching. + * + * @returns A promise that resolves to the block with the highest NLP match score, + * or `undefined` if no suitable match is found. */ - async matchBestNLP( - blocks: BlockFull[], - nlp: NLU.ParseEntities, - ): Promise { - const scoredBlocks = await Promise.all( - blocks.map(async (block) => { - const matchedPatterns = this.matchNLP(nlp, block) || []; - - const scores = await Promise.all( - matchedPatterns.map((pattern) => - this.calculateBlockScore(pattern, nlp), - ), + async matchBestNLP( + blocks: B[], + scoredEntities: NLU.ScoredEntities, + ): Promise { + const bestMatch = blocks.reduce( + (bestMatch, block) => { + const matchedPatterns = this.getMatchingNluPatterns( + scoredEntities, + block, ); - const maxScore = scores.length > 0 ? Math.max(...scores) : 0; + // Compute the score (Weighted sum = weight * confidence) + // for each of block NLU patterns + const score = matchedPatterns.reduce((maxScore, patterns) => { + const score = this.calculateNluPatternMatchScore( + patterns, + scoredEntities, + ); + return Math.max(maxScore, score); + }, 0); - return { block, score: maxScore }; - }), - ); - - const best = scoredBlocks.reduce( - (acc, curr) => (curr.score > acc.score ? curr : acc), + return score > bestMatch.score ? { block, score } : bestMatch; + }, { block: undefined, score: 0 }, ); - return best.block; + return bestMatch.block; } /** - * Computes the NLP score for a given block using its matched NLP patterns and parsed NLP entities. + * Calculates the total NLU pattern match score by summing the individual pattern scores + * for each pattern that matches a scored entity. * - * Each pattern is evaluated against the parsed NLP entities to determine matches based on entity name, - * value, and confidence. A score is computed using the entity's weight and the confidence level of the match. - * A penalty factor is optionally applied for entity-level matches to adjust the scoring. + * For each pattern in the list, the function attempts to find a matching entity in the + * NLU prediction. If a match is found, the score is computed using `computePatternScore`, + * potentially applying a penalty if the match is generic (entity-only). * - * The function uses a cache (`nlpCacheMap`) to avoid redundant database lookups for entity metadata. + * This scoring mechanism allows the system to prioritize more precise matches and + * quantify the overall alignment between predicted NLU entities and predefined patterns. * - * @param patterns - The NLP patterns associated with the block. - * @param nlp - The parsed NLP entities from the user input. - * @returns A numeric score representing how well the block matches the given NLP context. + * @param patterns - A list of patterns to evaluate against the NLU prediction. + * @param prediction - The scored entities resulting from NLU inference. + * @param [penaltyFactor=0.95] - Optional penalty factor to apply for generic matches (default is 0.95). + * + * @returns The total aggregated match score based on matched patterns and their computed scores. */ - async calculateBlockScore( + calculateNluPatternMatchScore( patterns: NlpPattern[], - nlp: NLU.ParseEntities, - ): Promise { - if (!patterns.length) return 0; + prediction: NLU.ScoredEntities, + penaltyFactor = 0.95, + ): number { + if (!patterns.length) { + throw new Error( + 'Unable to compute the NLU match score : patterns are missing', + ); + } - const nlpCacheMap = await this.entityService.getNlpMap(); - // @TODO Make nluPenaltyFactor configurable in UI settings - const nluPenaltyFactor = 0.95; - const patternScores: number[] = patterns - .filter(({ entity }) => nlpCacheMap.has(entity)) - .map((pattern) => { - const entityData = nlpCacheMap.get(pattern.entity); + return patterns.reduce((score, pattern) => { + const matchedEntity: NLU.ScoredEntity | undefined = + prediction.entities.find((e) => this.matchesNluEntity(e, pattern)); - const matchedEntity: NLU.ParseEntity | undefined = nlp.entities.find( - (e) => this.matchesEntityData(e, pattern, entityData!), + if (!matchedEntity) { + throw new Error( + 'Unable to compute the NLU match score : pattern / entity mismatch', ); - return this.computePatternScore( - matchedEntity, - pattern, - entityData!, - nluPenaltyFactor, - ); - }); + } - // Sum the scores - return patternScores.reduce((sum, score) => sum + score, 0); + const patternScore = this.computePatternScore( + matchedEntity, + pattern, + penaltyFactor, + ); + + return score + patternScore; + }, 0); } /** @@ -430,36 +436,44 @@ export class BlockService extends BaseService< * - If the pattern's match type is `'value'`, it further ensures that the entity's value matches the specified value in the pattern. * - Returns `true` if all conditions are met, otherwise `false`. */ - private matchesEntityData( - e: NLU.ParseEntity, + private matchesNluEntity( + { entity, value }: E, pattern: NlpPattern, - entityData: NlpEntityFull, ): boolean { return ( - e.entity === pattern.entity && - entityData.values?.some((v) => v.value === e.value) && - (pattern.match !== 'value' || e.value === pattern.value) + entity === pattern.entity && + (pattern.match !== 'value' || value === pattern.value) ); } /** - * Computes the score for a given entity based on its confidence, weight, and penalty factor. + * Computes a pattern score by applying a penalty factor based on the matching rule of the pattern. * - * @param entity - The `ParseEntity` to check, which may be `undefined` if no match is found. - * @param pattern - The `NlpPattern` object that specifies how to match the entity and its value. - * @param entityData - The cached data for the given entity, including `weight` and `values`. - * @param nlpPenaltyFactor - The penalty factor applied when the pattern's match type is 'entity'. - * @returns The computed score based on the entity's confidence, the cached weight, and the penalty factor. + * This scoring mechanism allows prioritization of more specific patterns (entity + value) over + * more generic ones (entity only). + * + * @param entity - The scored entity object containing the base score. + * @param pattern - The pattern definition to match against the entity. + * @param [penaltyFactor=0.95] - Optional penalty factor applied when the pattern only matches the entity (default is 0.95). + * + * @returns The final pattern score after applying any applicable penalty. */ private computePatternScore( - entity: NLU.ParseEntity | undefined, + entity: NLU.ScoredEntity, pattern: NlpPattern, - entityData: NlpEntityFull, - nlpPenaltyFactor: number, + penaltyFactor: number = 0.95, ): number { - if (!entity || !entity.confidence) return 0; - const penalty = pattern.match === 'entity' ? nlpPenaltyFactor : 1; - return entity.confidence * entityData.weight * penalty; + if (!entity || !pattern) { + throw new Error( + 'Unable to compute pattern score : missing entity/pattern', + ); + } + + // In case the pattern matches the entity regardless of the value (any) + // we apply a penalty so that we prioritize other patterns where both entity and value matches + const penalty = pattern.match === 'entity' ? penaltyFactor : 1; + + return entity.score * penalty; } /** diff --git a/api/src/chat/services/bot.service.spec.ts b/api/src/chat/services/bot.service.spec.ts index ed574e52..e88695d8 100644 --- a/api/src/chat/services/bot.service.spec.ts +++ b/api/src/chat/services/bot.service.spec.ts @@ -35,12 +35,17 @@ import { I18nService } from '@/i18n/services/i18n.service'; import { LanguageService } from '@/i18n/services/language.service'; import { NlpEntityRepository } from '@/nlp/repositories/nlp-entity.repository'; import { NlpSampleEntityRepository } from '@/nlp/repositories/nlp-sample-entity.repository'; +import { NlpSampleRepository } from '@/nlp/repositories/nlp-sample.repository'; import { NlpValueRepository } from '@/nlp/repositories/nlp-value.repository'; import { NlpEntityModel } from '@/nlp/schemas/nlp-entity.schema'; import { NlpSampleEntityModel } from '@/nlp/schemas/nlp-sample-entity.schema'; +import { NlpSampleModel } from '@/nlp/schemas/nlp-sample.schema'; import { NlpValueModel } from '@/nlp/schemas/nlp-value.schema'; import { NlpEntityService } from '@/nlp/services/nlp-entity.service'; +import { NlpSampleEntityService } from '@/nlp/services/nlp-sample-entity.service'; +import { NlpSampleService } from '@/nlp/services/nlp-sample.service'; import { NlpValueService } from '@/nlp/services/nlp-value.service'; +import { NlpService } from '@/nlp/services/nlp.service'; import { PluginService } from '@/plugins/plugins.service'; import { SettingService } from '@/setting/services/setting.service'; import { installBlockFixtures } from '@/utils/test/fixtures/block'; @@ -111,6 +116,7 @@ describe('BlockService', () => { NlpEntityModel, NlpSampleEntityModel, NlpValueModel, + NlpSampleModel, ]), JwtModule, ], @@ -127,6 +133,11 @@ describe('BlockService', () => { MessageRepository, MenuRepository, LanguageRepository, + ContextVarRepository, + NlpEntityRepository, + NlpSampleEntityRepository, + NlpValueRepository, + NlpSampleRepository, BlockService, CategoryService, ContentTypeService, @@ -140,13 +151,12 @@ describe('BlockService', () => { MenuService, WebChannelHandler, ContextVarService, - ContextVarRepository, LanguageService, NlpEntityService, - NlpEntityRepository, - NlpSampleEntityRepository, - NlpValueRepository, NlpValueService, + NlpSampleService, + NlpSampleEntityService, + NlpService, { provide: HelperService, useValue: {}, diff --git a/api/src/chat/services/chat.service.ts b/api/src/chat/services/chat.service.ts index bd6be3e2..3d82a01c 100644 --- a/api/src/chat/services/chat.service.ts +++ b/api/src/chat/services/chat.service.ts @@ -21,6 +21,8 @@ import { import EventWrapper from '@/channel/lib/EventWrapper'; import { config } from '@/config'; import { HelperService } from '@/helper/helper.service'; +import { HelperType } from '@/helper/types'; +import { LanguageService } from '@/i18n/services/language.service'; import { LoggerService } from '@/logger/logger.service'; import { WebsocketGateway } from '@/websocket/websocket.gateway'; @@ -46,6 +48,7 @@ export class ChatService { private readonly websocketGateway: WebsocketGateway, private readonly helperService: HelperService, private readonly attachmentService: AttachmentService, + private readonly languageService: LanguageService, ) {} /** @@ -330,15 +333,7 @@ export class ChatService { return; } - if (event.getText() && !event.getNLP()) { - try { - const helper = await this.helperService.getDefaultNluHelper(); - const nlp = await helper.predict(event.getText(), true); - event.setNLP(nlp); - } catch (err) { - this.logger.error('Unable to perform NLP parse', err); - } - } + await this.enrichEventWithNLU(event); this.botService.handleMessageEvent(event); } catch (err) { @@ -346,6 +341,40 @@ export class ChatService { } } + /** + * Enriches an incoming event by performing NLP inference and updating the sender's language profile if detected. + * + * @param event - The incoming event object containing user input and metadata. + * @returns Resolves when preprocessing is complete. Any errors are logged without throwing. + */ + async enrichEventWithNLU(event: EventWrapper) { + if (!event.getText() || event.getNLP()) { + return; + } + + try { + const helper = await this.helperService.getDefaultHelper(HelperType.NLU); + const nlp = await helper.predict(event.getText(), true); + + // Check & catch user language through NLP + if (nlp) { + const languages = await this.languageService.getLanguages(); + const spokenLanguage = nlp.entities.find( + (e) => e.entity === 'language', + ); + if (spokenLanguage && spokenLanguage.value in languages) { + const profile = event.getSender(); + profile.language = spokenLanguage.value; + event.setSender(profile); + } + } + + event.setNLP(nlp); + } catch (err) { + this.logger.error('Unable to perform NLP parse', err); + } + } + /** * Handle new subscriber and send notification the websocket * diff --git a/api/src/helper/types.ts b/api/src/helper/types.ts index 95f1601c..4e82e493 100644 --- a/api/src/helper/types.ts +++ b/api/src/helper/types.ts @@ -26,6 +26,14 @@ export namespace NLU { export interface ParseEntities { entities: ParseEntity[]; } + + export interface ScoredEntity extends ParseEntity { + score: number; // Computed as confidence * weight + } + + export interface ScoredEntities extends ParseEntities { + entities: ScoredEntity[]; + } } export namespace LLM { diff --git a/api/src/i18n/services/language.service.ts b/api/src/i18n/services/language.service.ts index 6b82eb47..c70e06b2 100644 --- a/api/src/i18n/services/language.service.ts +++ b/api/src/i18n/services/language.service.ts @@ -47,7 +47,7 @@ export class LanguageService extends BaseService< * and the corresponding value is the `Language` object. */ @Cacheable(LANGUAGES_CACHE_KEY) - async getLanguages() { + async getLanguages(): Promise> { const languages = await this.findAll(); return languages.reduce((acc, curr) => { return { diff --git a/api/src/nlp/repositories/nlp-entity.repository.spec.ts b/api/src/nlp/repositories/nlp-entity.repository.spec.ts index fd8f2262..a7441c7c 100644 --- a/api/src/nlp/repositories/nlp-entity.repository.spec.ts +++ b/api/src/nlp/repositories/nlp-entity.repository.spec.ts @@ -91,7 +91,7 @@ describe('NlpEntityRepository', () => { }); }); - describe('findPageAndPopulate', () => { + describe('findAndPopulate', () => { it('should return all nlp entities with populate', async () => { const pageQuery = getPageQuery({ sort: ['name', 'desc'], @@ -99,7 +99,7 @@ describe('NlpEntityRepository', () => { const firstNameValues = await nlpValueRepository.find({ entity: firstNameNlpEntity!.id, }); - const result = await nlpEntityRepository.findPageAndPopulate( + const result = await nlpEntityRepository.findAndPopulate( { _id: firstNameNlpEntity!.id }, pageQuery, ); diff --git a/api/src/nlp/repositories/nlp-value.repository.spec.ts b/api/src/nlp/repositories/nlp-value.repository.spec.ts index 179f0e76..dc5f7192 100644 --- a/api/src/nlp/repositories/nlp-value.repository.spec.ts +++ b/api/src/nlp/repositories/nlp-value.repository.spec.ts @@ -71,18 +71,15 @@ describe('NlpValueRepository', () => { }); }); - describe('findPageAndPopulate', () => { - it('should return all nlp entities with populate', async () => { + describe('findAndPopulate', () => { + it('should return all nlp values with populate', async () => { const pageQuery = getPageQuery({ - sort: ['value', 'desc'], + sort: ['createdAt', 'asc'], }); - const result = await nlpValueRepository.findPageAndPopulate( - {}, - pageQuery, - ); + const result = await nlpValueRepository.findAndPopulate({}, pageQuery); const nlpValueFixturesWithEntities = nlpValueFixtures.reduce( (acc, curr) => { - const ValueWithEntities = { + const fullValue: NlpValueFull = { ...curr, entity: nlpEntityFixtures[ parseInt(curr.entity!) @@ -90,13 +87,21 @@ describe('NlpValueRepository', () => { builtin: curr.builtin!, expressions: curr.expressions!, metadata: curr.metadata!, + id: '', + createdAt: new Date(), + updatedAt: new Date(), }; - acc.push(ValueWithEntities); + acc.push(fullValue); return acc; }, [] as TFixtures[], ); - expect(result).toEqualPayload(nlpValueFixturesWithEntities); + expect(result).toEqualPayload(nlpValueFixturesWithEntities, [ + 'id', + 'createdAt', + 'updatedAt', + 'metadata', + ]); }); }); diff --git a/api/src/nlp/services/nlp-entity.service.spec.ts b/api/src/nlp/services/nlp-entity.service.spec.ts index eae5b51f..91858e10 100644 --- a/api/src/nlp/services/nlp-entity.service.spec.ts +++ b/api/src/nlp/services/nlp-entity.service.spec.ts @@ -29,7 +29,7 @@ import { NlpValueModel } from '../schemas/nlp-value.schema'; import { NlpEntityService } from './nlp-entity.service'; import { NlpValueService } from './nlp-value.service'; -describe('nlpEntityService', () => { +describe('NlpEntityService', () => { let nlpEntityService: NlpEntityService; let nlpEntityRepository: NlpEntityRepository; let nlpValueRepository: NlpValueRepository; @@ -221,32 +221,47 @@ describe('nlpEntityService', () => { const result = await nlpEntityService.getNlpMap(); expect(result).toBeInstanceOf(Map); - expect(result.get('firstname')).toEqual( - expect.objectContaining({ + expect(result.get('firstname')).toEqualPayload( + { name: 'firstname', lookups: ['keywords'], doc: '', builtin: false, - weight: 1, + weight: 0.85, values: [ - expect.objectContaining({ + { value: 'jhon', expressions: ['john', 'joohn', 'jhonny'], builtin: true, doc: '', - }), + }, ], - }), + }, + ['id', 'createdAt', 'updatedAt', 'metadata', 'entity'], ); - expect(result.get('subject')).toEqual( - expect.objectContaining({ + expect(result.get('subject')).toEqualPayload( + { name: 'subject', lookups: ['trait'], doc: '', builtin: false, - weight: 1, - values: [], - }), + weight: 0.95, + values: [ + { + value: 'product', + expressions: [], + builtin: false, + doc: '', + }, + { + value: 'claim', + expressions: [], + builtin: false, + doc: '', + }, + ], + }, + ['id', 'createdAt', 'updatedAt', 'metadata', 'entity'], ); }); }); diff --git a/api/src/nlp/services/nlp-sample.service.spec.ts b/api/src/nlp/services/nlp-sample.service.spec.ts index e24e4b47..d1c3798c 100644 --- a/api/src/nlp/services/nlp-sample.service.spec.ts +++ b/api/src/nlp/services/nlp-sample.service.spec.ts @@ -217,7 +217,10 @@ describe('NlpSampleService', () => { .mockResolvedValue([{ name: 'intent' } as NlpEntity]); jest .spyOn(languageService, 'getLanguages') - .mockResolvedValue({ en: { id: '1' } }); + .mockResolvedValue({ en: { id: '1' } } as unknown as Record< + string, + Language + >); jest .spyOn(languageService, 'getDefaultLanguage') .mockResolvedValue({ code: 'en' } as Language); @@ -240,7 +243,10 @@ describe('NlpSampleService', () => { .mockResolvedValue([{ name: 'intent' } as NlpEntity]); jest .spyOn(languageService, 'getLanguages') - .mockResolvedValue({ en: { id: '1' } }); + .mockResolvedValue({ en: { id: '1' } } as unknown as Record< + string, + Language + >); jest .spyOn(languageService, 'getDefaultLanguage') .mockResolvedValue({ code: 'en' } as Language); @@ -258,7 +264,10 @@ describe('NlpSampleService', () => { it('should successfully process and save valid dataset rows', async () => { const mockData = 'text,intent,language\nHi,greet,en\nBye,bye,en'; - const mockLanguages = { en: { id: '1' } }; + const mockLanguages = { en: { id: '1' } } as unknown as Record< + string, + Language + >; jest .spyOn(languageService, 'getLanguages') diff --git a/api/src/nlp/services/nlp-value.service.spec.ts b/api/src/nlp/services/nlp-value.service.spec.ts index f55b5a68..50bfcca1 100644 --- a/api/src/nlp/services/nlp-value.service.spec.ts +++ b/api/src/nlp/services/nlp-value.service.spec.ts @@ -98,25 +98,33 @@ describe('NlpValueService', () => { }); }); - describe('findPageAndPopulate', () => { - it('should return all nlp entities with populate', async () => { - const pageQuery = getPageQuery({ sort: ['value', 'desc'] }); - const result = await nlpValueService.findPageAndPopulate({}, pageQuery); + describe('findAndPopulate', () => { + it('should return all nlp values with populate', async () => { + const pageQuery = getPageQuery({ sort: ['createdAt', 'asc'] }); + const result = await nlpValueService.findAndPopulate({}, pageQuery); const nlpValueFixturesWithEntities = nlpValueFixtures.reduce( (acc, curr) => { - const ValueWithEntities = { + const fullValue: NlpValueFull = { ...curr, entity: nlpEntityFixtures[parseInt(curr.entity!)] as NlpEntity, expressions: curr.expressions!, - metadata: curr.metadata!, builtin: curr.builtin!, + metadata: {}, + id: '', + createdAt: new Date(), + updatedAt: new Date(), }; - acc.push(ValueWithEntities); + acc.push(fullValue); return acc; }, [] as Omit[], ); - expect(result).toEqualPayload(nlpValueFixturesWithEntities); + expect(result).toEqualPayload(nlpValueFixturesWithEntities, [ + 'id', + 'createdAt', + 'updatedAt', + 'metadata', + ]); }); }); diff --git a/api/src/nlp/services/nlp.service.spec.ts b/api/src/nlp/services/nlp.service.spec.ts new file mode 100644 index 00000000..b5a3cd51 --- /dev/null +++ b/api/src/nlp/services/nlp.service.spec.ts @@ -0,0 +1,134 @@ +/* + * 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 { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { MongooseModule } from '@nestjs/mongoose'; + +import { HelperService } from '@/helper/helper.service'; +import { LanguageRepository } from '@/i18n/repositories/language.repository'; +import { LanguageModel } from '@/i18n/schemas/language.schema'; +import { LanguageService } from '@/i18n/services/language.service'; +import { SettingRepository } from '@/setting/repositories/setting.repository'; +import { SettingModel } from '@/setting/schemas/setting.schema'; +import { SettingSeeder } from '@/setting/seeds/setting.seed'; +import { SettingService } from '@/setting/services/setting.service'; +import { installNlpValueFixtures } from '@/utils/test/fixtures/nlpvalue'; +import { + closeInMongodConnection, + rootMongooseTestModule, +} from '@/utils/test/test'; +import { buildTestingMocks } from '@/utils/test/utils'; + +import { NlpEntityRepository } from '../repositories/nlp-entity.repository'; +import { NlpSampleEntityRepository } from '../repositories/nlp-sample-entity.repository'; +import { NlpSampleRepository } from '../repositories/nlp-sample.repository'; +import { NlpValueRepository } from '../repositories/nlp-value.repository'; +import { NlpEntityModel } from '../schemas/nlp-entity.schema'; +import { NlpSampleEntityModel } from '../schemas/nlp-sample-entity.schema'; +import { NlpSampleModel } from '../schemas/nlp-sample.schema'; +import { NlpValueModel } from '../schemas/nlp-value.schema'; + +import { NlpEntityService } from './nlp-entity.service'; +import { NlpSampleEntityService } from './nlp-sample-entity.service'; +import { NlpSampleService } from './nlp-sample.service'; +import { NlpValueService } from './nlp-value.service'; +import { NlpService } from './nlp.service'; + +describe('NlpService', () => { + let nlpService: NlpService; + + beforeAll(async () => { + const { getMocks } = await buildTestingMocks({ + imports: [ + rootMongooseTestModule(installNlpValueFixtures), + MongooseModule.forFeature([ + NlpEntityModel, + NlpValueModel, + NlpSampleEntityModel, + NlpSampleModel, + LanguageModel, + SettingModel, + ]), + ], + providers: [ + NlpService, + NlpEntityService, + NlpEntityRepository, + NlpValueService, + NlpSampleService, + NlpSampleEntityService, + HelperService, + LanguageService, + SettingService, + NlpValueRepository, + NlpSampleEntityRepository, + NlpSampleRepository, + SettingRepository, + SettingSeeder, + LanguageRepository, + { + provide: CACHE_MANAGER, + useValue: { + del: jest.fn(), + set: jest.fn(), + get: jest.fn(), + }, + }, + ], + }); + [nlpService] = await getMocks([NlpService]); + }); + + afterAll(closeInMongodConnection); + + afterEach(jest.clearAllMocks); + + describe('computePredictionScore()', () => { + it('should compute score as confidence * weight for matched entities', async () => { + const result = await nlpService.computePredictionScore({ + entities: [ + { entity: 'intent', value: 'greeting', confidence: 0.98 }, + { entity: 'subject', value: 'product', confidence: 0.9 }, + { entity: 'firstname', value: 'Jhon', confidence: 0.78 }, + { entity: 'irrelevant', value: 'test', confidence: 1 }, + ], + }); + + expect(result).toEqual({ + entities: [ + { + entity: 'intent', + value: 'greeting', + confidence: 0.98, + score: 0.98, + }, + { + entity: 'subject', + value: 'product', + confidence: 0.9, + score: 0.855, + }, + { + entity: 'firstname', + value: 'Jhon', + confidence: 0.78, + score: 0.663, + }, + ], + }); + }); + + it('should return empty array if no entity matches', async () => { + const result = await nlpService.computePredictionScore({ + entities: [{ entity: 'unknown', value: 'x', confidence: 1 }], + }); + + expect(result).toEqual({ entities: [] }); + }); + }); +}); diff --git a/api/src/nlp/services/nlp.service.ts b/api/src/nlp/services/nlp.service.ts index 45e3a78c..905a9465 100644 --- a/api/src/nlp/services/nlp.service.ts +++ b/api/src/nlp/services/nlp.service.ts @@ -10,6 +10,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { HelperService } from '@/helper/helper.service'; +import { NLU } from '@/helper/types'; import { LoggerService } from '@/logger/logger.service'; import { NlpEntity, NlpEntityDocument } from '../schemas/nlp-entity.schema'; @@ -29,6 +30,36 @@ export class NlpService { protected readonly helperService: HelperService, ) {} + /** + * Computes a prediction score for each parsed NLU entity based on its confidence and a predefined weight. + * + * `score = confidence * weight` + * + * If a weight is not defined for a given entity, a default of 1 is used. + * + * @param input - The input object containing parsed entities. + * @param input.entities - The list of entities returned from NLU inference. + * + * @returns A promise that resolves to a list of scored entities. + */ + async computePredictionScore({ + entities, + }: NLU.ParseEntities): Promise { + const nlpMap = await this.nlpEntityService.getNlpMap(); + + const scoredEntities = entities + .filter(({ entity }) => nlpMap.has(entity)) + .map((e) => { + const entity = nlpMap.get(e.entity)!; + return { + ...e, + score: e.confidence * (entity.weight || 1), + }; + }); + + return { entities: scoredEntities }; + } + /** * Handles the event triggered when a new NLP entity is created. Synchronizes the entity with the external NLP provider. * diff --git a/api/src/utils/test/fixtures/nlpentity.ts b/api/src/utils/test/fixtures/nlpentity.ts index 78f29236..4d2401ec 100644 --- a/api/src/utils/test/fixtures/nlpentity.ts +++ b/api/src/utils/test/fixtures/nlpentity.ts @@ -24,7 +24,7 @@ export const nlpEntityFixtures: NlpEntityCreateDto[] = [ lookups: ['keywords'], doc: '', builtin: false, - weight: 1, + weight: 0.85, }, { name: 'built_in', @@ -38,7 +38,7 @@ export const nlpEntityFixtures: NlpEntityCreateDto[] = [ lookups: ['trait'], doc: '', builtin: false, - weight: 1, + weight: 0.95, }, ]; diff --git a/api/src/utils/test/fixtures/nlpvalue.ts b/api/src/utils/test/fixtures/nlpvalue.ts index cf99f63c..d83a582a 100644 --- a/api/src/utils/test/fixtures/nlpvalue.ts +++ b/api/src/utils/test/fixtures/nlpvalue.ts @@ -11,7 +11,7 @@ import mongoose from 'mongoose'; import { NlpValueCreateDto } from '@/nlp/dto/nlp-value.dto'; import { NlpValueModel } from '@/nlp/schemas/nlp-value.schema'; -import { installNlpEntityFixtures } from './nlpentity'; +import { installNlpEntityFixtures, nlpEntityFixtures } from './nlpentity'; export const nlpValueFixtures: NlpValueCreateDto[] = [ { @@ -56,16 +56,36 @@ export const nlpValueFixtures: NlpValueCreateDto[] = [ builtin: false, doc: '', }, + { + entity: '3', + value: 'product', + expressions: [], + builtin: false, + doc: '', + }, + { + entity: '3', + value: 'claim', + expressions: [], + builtin: false, + doc: '', + }, ]; export const installNlpValueFixtures = async () => { const nlpEntities = await installNlpEntityFixtures(); const NlpValue = mongoose.model(NlpValueModel.name, NlpValueModel.schema); + const nlpValues = await NlpValue.insertMany( nlpValueFixtures.map((v) => ({ ...v, - entity: v?.entity ? nlpEntities[parseInt(v.entity)].id : null, + entity: v?.entity + ? nlpEntities.find( + (e) => + e.name === nlpEntityFixtures[parseInt(v.entity as string)].name, + ).id + : null, })), ); return { nlpEntities, nlpValues }; diff --git a/api/src/utils/test/mocks/block.ts b/api/src/utils/test/mocks/block.ts index e58066f6..c1c45611 100644 --- a/api/src/utils/test/mocks/block.ts +++ b/api/src/utils/test/mocks/block.ts @@ -230,23 +230,20 @@ export const blockGetStarted = { value: 'Livre', type: PayloadType.attachments, }, - [ - { - entity: 'intent', - match: 'value', - value: 'greeting', - }, - { - entity: 'firstname', - match: 'entity', - }, - ], ], trigger_labels: customerLabelsMock, message: ['Welcome! How are you ? '], } as unknown as BlockFull; -export const mockNlpPatternsSetOne: NlpPattern[] = [ +export const mockNlpGreetingPatterns: NlpPattern[] = [ + { + entity: 'intent', + match: 'value', + value: 'greeting', + }, +]; + +export const mockNlpGreetingNamePatterns: NlpPattern[] = [ { entity: 'intent', match: 'value', @@ -259,7 +256,20 @@ export const mockNlpPatternsSetOne: NlpPattern[] = [ }, ]; -export const mockNlpPatternsSetTwo: NlpPattern[] = [ +export const mockNlpGreetingWrongNamePatterns: NlpPattern[] = [ + { + entity: 'intent', + match: 'value', + value: 'greeting', + }, + { + entity: 'firstname', + match: 'value', + value: 'doe', + }, +]; + +export const mockNlpAffirmationPatterns: NlpPattern[] = [ { entity: 'intent', match: 'value', @@ -272,7 +282,7 @@ export const mockNlpPatternsSetTwo: NlpPattern[] = [ }, ]; -export const mockNlpPatternsSetThree: NlpPattern[] = [ +export const mockNlpGreetingAnyNamePatterns: NlpPattern[] = [ { entity: 'intent', match: 'value', @@ -284,33 +294,6 @@ export const mockNlpPatternsSetThree: NlpPattern[] = [ }, ]; -export const mockNlpBlock: BlockFull = { - ...baseBlockInstance, - name: 'Mock Nlp', - patterns: [ - 'Hello', - '/we*lcome/', - { label: 'Mock Nlp', value: 'MOCK_NLP' }, - - mockNlpPatternsSetOne, - [ - { - entity: 'intent', - match: 'value', - value: 'greeting', - }, - { - entity: 'firstname', - match: 'value', - value: 'doe', - }, - ], - ], - - trigger_labels: customerLabelsMock, - message: ['Good to see you again '], -} as unknown as BlockFull; - export const mockModifiedNlpBlock: BlockFull = { ...baseBlockInstance, name: 'Modified Mock Nlp', @@ -318,7 +301,7 @@ export const mockModifiedNlpBlock: BlockFull = { 'Hello', '/we*lcome/', { label: 'Modified Mock Nlp', value: 'MODIFIED_MOCK_NLP' }, - mockNlpPatternsSetThree, + mockNlpGreetingAnyNamePatterns, ], trigger_labels: customerLabelsMock, message: ['Hello there'], @@ -331,7 +314,7 @@ export const mockModifiedNlpBlockOne: BlockFull = { 'Hello', '/we*lcome/', { label: 'Modified Mock Nlp One', value: 'MODIFIED_MOCK_NLP_ONE' }, - mockNlpPatternsSetTwo, + mockNlpAffirmationPatterns, [ { entity: 'firstname', @@ -356,7 +339,7 @@ export const mockModifiedNlpBlockTwo: BlockFull = { match: 'entity', }, ], - mockNlpPatternsSetThree, + mockNlpGreetingAnyNamePatterns, ], trigger_labels: customerLabelsMock, message: ['Hello Madam'], @@ -400,5 +383,3 @@ export const blockCarouselMock = { } as unknown as BlockFull; export const blocks: BlockFull[] = [blockGetStarted, blockEmpty]; - -export const nlpBlocks: BlockFull[] = [blockGetStarted, mockNlpBlock]; diff --git a/api/src/utils/test/mocks/nlp.ts b/api/src/utils/test/mocks/nlp.ts index 04c1742d..2cf7ae68 100644 --- a/api/src/utils/test/mocks/nlp.ts +++ b/api/src/utils/test/mocks/nlp.ts @@ -8,42 +8,36 @@ import { NLU } from '@/helper/types'; -export const nlpEntitiesGreeting: NLU.ParseEntities = { +export const mockNlpGreetingNameEntities: NLU.ScoredEntities = { entities: [ { entity: 'intent', value: 'greeting', confidence: 0.999, + score: 0.999, }, { entity: 'firstname', value: 'jhon', confidence: 0.5, + score: 0.425, }, + ], +}; + +export const mockNlpGreetingFullNameEntities: NLU.ParseEntities = { + entities: [ + ...mockNlpGreetingNameEntities.entities, { entity: 'lastname', value: 'doe', confidence: 0.5, + score: 0.425, }, ], }; -export const mockNlpEntitiesSetOne: NLU.ParseEntities = { - entities: [ - { - entity: 'intent', - value: 'greeting', - confidence: 0.999, - }, - { - entity: 'firstname', - value: 'jhon', - confidence: 0.5, - }, - ], -}; - -export const mockNlpEntitiesSetTwo: NLU.ParseEntities = { +export const mockNlpGreetingWrongNameEntities: NLU.ParseEntities = { entities: [ { entity: 'intent', From a054ee542e04f7b5573c635247dc7bd47f882c74 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Mon, 12 May 2025 14:40:38 +0100 Subject: [PATCH 10/15] fix: minor fixes --- api/src/chat/services/block.service.spec.ts | 6 +++--- api/src/chat/services/block.service.ts | 20 ++++++-------------- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/api/src/chat/services/block.service.spec.ts b/api/src/chat/services/block.service.spec.ts index 5cbbc1c3..432bf5e3 100644 --- a/api/src/chat/services/block.service.spec.ts +++ b/api/src/chat/services/block.service.spec.ts @@ -416,7 +416,7 @@ describe('BlockService', () => { blockService, 'calculateNluPatternMatchScore', ); - const bestBlock = await blockService.matchBestNLP( + const bestBlock = blockService.matchBestNLP( blocks, mockNlpGreetingNameEntities, ); @@ -458,7 +458,7 @@ describe('BlockService', () => { blockService, 'calculateNluPatternMatchScore', ); - const bestBlock = await blockService.matchBestNLP(blocks, nlp); + const bestBlock = blockService.matchBestNLP(blocks, nlp); // Ensure calculateBlockScore was called at least once for each block expect(calculateBlockScoreSpy).toHaveBeenCalledTimes(3); // Called for each block @@ -476,7 +476,7 @@ describe('BlockService', () => { blockGetStarted, ]; - const bestBlock = await blockService.matchBestNLP( + const bestBlock = blockService.matchBestNLP( blocks, mockNlpGreetingNameEntities, ); diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index f4753ea3..add9c8a4 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -181,7 +181,7 @@ export class BlockService extends BaseService< const scoredEntities = await this.nlpService.computePredictionScore(nlp); - block = await this.matchBestNLP(filteredBlocks, scoredEntities); + block = this.matchBestNLP(filteredBlocks, scoredEntities); } } @@ -344,10 +344,10 @@ export class BlockService extends BaseService< * @returns A promise that resolves to the block with the highest NLP match score, * or `undefined` if no suitable match is found. */ - async matchBestNLP( + matchBestNLP( blocks: B[], scoredEntities: NLU.ScoredEntities, - ): Promise { + ): B | undefined { const bestMatch = blocks.reduce( (bestMatch, block) => { const matchedPatterns = this.getMatchingNluPatterns( @@ -405,17 +405,9 @@ export class BlockService extends BaseService< const matchedEntity: NLU.ScoredEntity | undefined = prediction.entities.find((e) => this.matchesNluEntity(e, pattern)); - if (!matchedEntity) { - throw new Error( - 'Unable to compute the NLU match score : pattern / entity mismatch', - ); - } - - const patternScore = this.computePatternScore( - matchedEntity, - pattern, - penaltyFactor, - ); + const patternScore = matchedEntity + ? this.computePatternScore(matchedEntity, pattern, penaltyFactor) + : 0; return score + patternScore; }, 0); From 44a42e474deef32f80b247915d1a366e659c51fa Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Mon, 12 May 2025 14:49:04 +0100 Subject: [PATCH 11/15] fix: update dto --- .../controllers/nlp-entity.controller.spec.ts | 21 ++---------------- .../nlp/controllers/nlp-entity.controller.ts | 22 +++++-------------- api/src/nlp/dto/nlp-entity.dto.ts | 21 +++++++++++++++++- api/src/nlp/services/nlp-entity.service.ts | 6 +++-- api/src/nlp/services/nlp.service.ts | 4 ++-- 5 files changed, 34 insertions(+), 40 deletions(-) diff --git a/api/src/nlp/controllers/nlp-entity.controller.spec.ts b/api/src/nlp/controllers/nlp-entity.controller.spec.ts index 484f41c8..db405e63 100644 --- a/api/src/nlp/controllers/nlp-entity.controller.spec.ts +++ b/api/src/nlp/controllers/nlp-entity.controller.spec.ts @@ -9,7 +9,6 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { BadRequestException, - ConflictException, MethodNotAllowedException, NotFoundException, } from '@nestjs/common'; @@ -29,7 +28,7 @@ import { import { TFixtures } from '@/utils/test/types'; import { buildTestingMocks } from '@/utils/test/utils'; -import { NlpEntityCreateDto } from '../dto/nlp-entity.dto'; +import { NlpEntityCreateDto, NlpEntityUpdateDto } from '../dto/nlp-entity.dto'; import { NlpEntityRepository } from '../repositories/nlp-entity.repository'; import { NlpSampleEntityRepository } from '../repositories/nlp-sample-entity.repository'; import { NlpValueRepository } from '../repositories/nlp-value.repository'; @@ -270,24 +269,8 @@ describe('NlpEntityController', () => { ).rejects.toThrow(NotFoundException); }); - it('should throw an exception if entity is builtin but weight not provided', async () => { - const updateNlpEntity: NlpEntityCreateDto = { - name: 'updated', - doc: '', - lookups: ['trait'], - builtin: false, - }; - await expect( - nlpEntityController.updateOne(buitInEntityId!, updateNlpEntity), - ).rejects.toThrow(ConflictException); - }); - it('should update weight if entity is builtin and weight is provided', async () => { - const updatedNlpEntity: NlpEntityCreateDto = { - name: 'updated', - doc: '', - lookups: ['trait'], - builtin: false, + const updatedNlpEntity: NlpEntityUpdateDto = { weight: 4, }; const findOneSpy = jest.spyOn(nlpEntityService, 'findOne'); diff --git a/api/src/nlp/controllers/nlp-entity.controller.ts b/api/src/nlp/controllers/nlp-entity.controller.ts index af549526..32d22b11 100644 --- a/api/src/nlp/controllers/nlp-entity.controller.ts +++ b/api/src/nlp/controllers/nlp-entity.controller.ts @@ -9,7 +9,6 @@ import { BadRequestException, Body, - ConflictException, Controller, Delete, Get, @@ -34,7 +33,7 @@ import { PopulatePipe } from '@/utils/pipes/populate.pipe'; import { SearchFilterPipe } from '@/utils/pipes/search-filter.pipe'; import { TFilterQuery } from '@/utils/types/filter.types'; -import { NlpEntityCreateDto } from '../dto/nlp-entity.dto'; +import { NlpEntityCreateDto, NlpEntityUpdateDto } from '../dto/nlp-entity.dto'; import { NlpEntity, NlpEntityFull, @@ -143,7 +142,7 @@ export class NlpEntityController extends BaseController< * This endpoint allows updating an existing NLP entity. The entity must not be a built-in entity. * * @param id - The ID of the NLP entity to update. - * @param updateNlpEntityDto - The new data for the NLP entity. + * @param nlpEntityDto - The new data for the NLP entity. * * @returns The updated NLP entity. */ @@ -151,7 +150,7 @@ export class NlpEntityController extends BaseController< @Patch(':id') async updateOne( @Param('id') id: string, - @Body() updateNlpEntityDto: NlpEntityCreateDto, + @Body() nlpEntityDto: NlpEntityUpdateDto, ): Promise { const nlpEntity = await this.nlpEntityService.findOne(id); if (!nlpEntity) { @@ -159,21 +158,12 @@ export class NlpEntityController extends BaseController< throw new NotFoundException(`NLP Entity with ID ${id} not found`); } - if (nlpEntity.builtin) { + if (nlpEntity.builtin && nlpEntityDto.weight) { // Only allow weight update for builtin entities - if (updateNlpEntityDto.weight) { - return await this.nlpEntityService.updateWeight( - id, - updateNlpEntityDto.weight, - ); - } else { - throw new ConflictException( - `Cannot update builtin NLP Entity ${nlpEntity.name} except for weight`, - ); - } + return await this.nlpEntityService.updateWeight(id, nlpEntityDto.weight); } - return await this.nlpEntityService.updateOne(id, updateNlpEntityDto); + return await this.nlpEntityService.updateOne(id, nlpEntityDto); } /** diff --git a/api/src/nlp/dto/nlp-entity.dto.ts b/api/src/nlp/dto/nlp-entity.dto.ts index 86ddf7d8..d82b6689 100644 --- a/api/src/nlp/dto/nlp-entity.dto.ts +++ b/api/src/nlp/dto/nlp-entity.dto.ts @@ -55,7 +55,25 @@ export class NlpEntityCreateDto { type: Number, }) @IsOptional() - @Validate((value) => value > 0, { + @Validate((value: number) => value > 0, { + message: 'Weight must be a strictly positive number', + }) + @IsNumber({ allowNaN: false, allowInfinity: false }) + weight?: number; +} + +export class NlpEntityUpdateDto { + @ApiPropertyOptional({ type: String }) + @IsString() + @IsOptional() + foreign_id?: string; + + @ApiPropertyOptional({ + description: 'Nlp entity associated weight for next block triggering', + type: Number, + }) + @IsOptional() + @Validate((value: number) => value > 0, { message: 'Weight must be a strictly positive number', }) @IsNumber({ allowNaN: false, allowInfinity: false }) @@ -64,4 +82,5 @@ export class NlpEntityCreateDto { export type NlpEntityDto = DtoConfig<{ create: NlpEntityCreateDto; + update: NlpEntityUpdateDto; }>; diff --git a/api/src/nlp/services/nlp-entity.service.ts b/api/src/nlp/services/nlp-entity.service.ts index 2704dabd..0f38c920 100644 --- a/api/src/nlp/services/nlp-entity.service.ts +++ b/api/src/nlp/services/nlp-entity.service.ts @@ -7,7 +7,7 @@ */ import { CACHE_MANAGER } from '@nestjs/cache-manager'; -import { Inject, Injectable } from '@nestjs/common'; +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { Cache } from 'cache-manager'; @@ -64,7 +64,9 @@ export class NlpEntityService extends BaseService< */ async updateWeight(id: string, updatedWeight: number): Promise { if (updatedWeight <= 0) { - throw new Error('Weight must be a strictly positive number'); + throw new BadRequestException( + 'Weight must be a strictly positive number', + ); } return await this.repository.updateOne(id, { weight: updatedWeight }); diff --git a/api/src/nlp/services/nlp.service.ts b/api/src/nlp/services/nlp.service.ts index 905a9465..6a779648 100644 --- a/api/src/nlp/services/nlp.service.ts +++ b/api/src/nlp/services/nlp.service.ts @@ -10,7 +10,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { HelperService } from '@/helper/helper.service'; -import { NLU } from '@/helper/types'; +import { HelperType, NLU } from '@/helper/types'; import { LoggerService } from '@/logger/logger.service'; import { NlpEntity, NlpEntityDocument } from '../schemas/nlp-entity.schema'; @@ -70,7 +70,7 @@ export class NlpService { async handleEntityCreate(entity: NlpEntityDocument) { // Synchonize new entity with NLP try { - const helper = await this.helperService.getDefaultNluHelper(); + 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( From f2fede7e685285237b9ef2808ac5297164816d68 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Mon, 12 May 2025 14:49:42 +0100 Subject: [PATCH 12/15] fix: controller unit test --- api/src/nlp/controllers/nlp-entity.controller.spec.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/api/src/nlp/controllers/nlp-entity.controller.spec.ts b/api/src/nlp/controllers/nlp-entity.controller.spec.ts index db405e63..6e82b9cd 100644 --- a/api/src/nlp/controllers/nlp-entity.controller.spec.ts +++ b/api/src/nlp/controllers/nlp-entity.controller.spec.ts @@ -290,11 +290,7 @@ describe('NlpEntityController', () => { }); it('should update only the weight of the builtin entity', async () => { - const updatedNlpEntity: NlpEntityCreateDto = { - name: 'updated', - doc: '', - lookups: ['trait'], - builtin: false, + const updatedNlpEntity: NlpEntityUpdateDto = { weight: 8, }; const originalEntity: NlpEntity | null = await nlpEntityService.findOne( From 6ba4c76440385b9aa7e5483168401af633f7cfb7 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Mon, 12 May 2025 14:55:36 +0100 Subject: [PATCH 13/15] fix: restore conflict exception --- .../controllers/nlp-entity.controller.spec.ts | 12 ++++++++++++ api/src/nlp/controllers/nlp-entity.controller.ts | 16 +++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/api/src/nlp/controllers/nlp-entity.controller.spec.ts b/api/src/nlp/controllers/nlp-entity.controller.spec.ts index 6e82b9cd..2f57c62f 100644 --- a/api/src/nlp/controllers/nlp-entity.controller.spec.ts +++ b/api/src/nlp/controllers/nlp-entity.controller.spec.ts @@ -9,6 +9,7 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { BadRequestException, + ConflictException, MethodNotAllowedException, NotFoundException, } from '@nestjs/common'; @@ -289,6 +290,17 @@ describe('NlpEntityController', () => { expect(result.weight).toBe(updatedNlpEntity.weight); }); + it('should throw an exception if entity is builtin but weight not provided', async () => { + await expect( + nlpEntityController.updateOne(buitInEntityId!, { + name: 'updated', + doc: '', + lookups: ['trait'], + builtin: false, + } as any), + ).rejects.toThrow(ConflictException); + }); + it('should update only the weight of the builtin entity', async () => { const updatedNlpEntity: NlpEntityUpdateDto = { weight: 8, diff --git a/api/src/nlp/controllers/nlp-entity.controller.ts b/api/src/nlp/controllers/nlp-entity.controller.ts index 32d22b11..1deb0c83 100644 --- a/api/src/nlp/controllers/nlp-entity.controller.ts +++ b/api/src/nlp/controllers/nlp-entity.controller.ts @@ -9,6 +9,7 @@ import { BadRequestException, Body, + ConflictException, Controller, Delete, Get, @@ -158,9 +159,18 @@ export class NlpEntityController extends BaseController< throw new NotFoundException(`NLP Entity with ID ${id} not found`); } - if (nlpEntity.builtin && nlpEntityDto.weight) { - // Only allow weight update for builtin entities - return await this.nlpEntityService.updateWeight(id, nlpEntityDto.weight); + if (nlpEntity.builtin) { + if (nlpEntityDto.weight) { + // Only allow weight update for builtin entities + return await this.nlpEntityService.updateWeight( + id, + nlpEntityDto.weight, + ); + } else { + throw new ConflictException( + `Cannot update builtin NLP Entity ${nlpEntity.name} except for weight`, + ); + } } return await this.nlpEntityService.updateOne(id, nlpEntityDto); From 8df658b4299549a1c2a866f0fa8e3055a70cb04f Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Mon, 12 May 2025 17:07:39 +0100 Subject: [PATCH 14/15] fix: remove unused mock --- api/src/utils/test/mocks/nlp.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/api/src/utils/test/mocks/nlp.ts b/api/src/utils/test/mocks/nlp.ts index 2cf7ae68..7c134391 100644 --- a/api/src/utils/test/mocks/nlp.ts +++ b/api/src/utils/test/mocks/nlp.ts @@ -32,22 +32,6 @@ export const mockNlpGreetingFullNameEntities: NLU.ParseEntities = { entity: 'lastname', value: 'doe', confidence: 0.5, - score: 0.425, - }, - ], -}; - -export const mockNlpGreetingWrongNameEntities: NLU.ParseEntities = { - entities: [ - { - entity: 'intent', - value: 'greeting', - confidence: 0.94, - }, - { - entity: 'firstname', - value: 'doe', - confidence: 0.33, }, ], }; From 27cf8a343c6d1b9e6f95d7a49d4f1c355c801734 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Mon, 12 May 2025 17:19:06 +0100 Subject: [PATCH 15/15] fix: nitpicks --- api/src/chat/services/block.service.ts | 14 ++++++-------- api/src/utils/test/mocks/nlp.ts | 11 ++++++++++- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index add9c8a4..0853d2b2 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -181,7 +181,9 @@ export class BlockService extends BaseService< const scoredEntities = await this.nlpService.computePredictionScore(nlp); - block = this.matchBestNLP(filteredBlocks, scoredEntities); + if (scoredEntities.entities.length > 0) { + block = this.matchBestNLP(filteredBlocks, scoredEntities); + } } } @@ -395,10 +397,8 @@ export class BlockService extends BaseService< prediction: NLU.ScoredEntities, penaltyFactor = 0.95, ): number { - if (!patterns.length) { - throw new Error( - 'Unable to compute the NLU match score : patterns are missing', - ); + if (!patterns.length || !prediction.entities.length) { + return 0; } return patterns.reduce((score, pattern) => { @@ -456,9 +456,7 @@ export class BlockService extends BaseService< penaltyFactor: number = 0.95, ): number { if (!entity || !pattern) { - throw new Error( - 'Unable to compute pattern score : missing entity/pattern', - ); + return 0; } // In case the pattern matches the entity regardless of the value (any) diff --git a/api/src/utils/test/mocks/nlp.ts b/api/src/utils/test/mocks/nlp.ts index 7c134391..f7f3b5ca 100644 --- a/api/src/utils/test/mocks/nlp.ts +++ b/api/src/utils/test/mocks/nlp.ts @@ -27,7 +27,16 @@ export const mockNlpGreetingNameEntities: NLU.ScoredEntities = { export const mockNlpGreetingFullNameEntities: NLU.ParseEntities = { entities: [ - ...mockNlpGreetingNameEntities.entities, + { + entity: 'intent', + value: 'greeting', + confidence: 0.999, + }, + { + entity: 'firstname', + value: 'jhon', + confidence: 0.5, + }, { entity: 'lastname', value: 'doe',