diff --git a/api/src/chat/schemas/types/pattern.ts b/api/src/chat/schemas/types/pattern.ts index d64da857..052656ab 100644 --- a/api/src/chat/schemas/types/pattern.ts +++ b/api/src/chat/schemas/types/pattern.ts @@ -64,3 +64,16 @@ export type NlpPatternMatchResult = { block: BlockFull; matchedPattern: NlpPattern[]; }; + +export function isNlpPattern( + pattern: unknown, +): pattern is { entity: string; match: 'entity' | 'value' } { + return ( + (typeof pattern === 'object' && + pattern !== null && + 'entity' in pattern && + 'match' in pattern && + (pattern as any).match === 'entity') || + (pattern as any).match === 'value' + ); +} diff --git a/api/src/chat/services/block.service.spec.ts b/api/src/chat/services/block.service.spec.ts index c8194a6e..bfa1f2f8 100644 --- a/api/src/chat/services/block.service.spec.ts +++ b/api/src/chat/services/block.service.spec.ts @@ -63,6 +63,7 @@ import { subscriberContextBlankInstance, } from '@/utils/test/mocks/conversation'; import { + mockNlpCacheMap, mockNlpEntitiesSetOne, nlpEntitiesGreeting, } from '@/utils/test/mocks/nlp'; @@ -85,23 +86,10 @@ import { BlockService } from './block.service'; import { CategoryService } from './category.service'; // Create a mock for the NlpEntityService -const mockNlpEntityService: Partial> = - { - findAndPopulate: jest.fn().mockResolvedValue([ - { - _id: '67e3e41eff551ca5be70559c', - name: 'intent', - weight: 1, - values: [{ value: 'greeting' }, { value: 'affirmation' }], - }, - { - _id: '67e3e41eff551ca5be70559d', - name: 'firstname', - weight: 1, - values: [{ value: 'jhon' }, { value: 'doe' }], - }, - ]), - }; +// const mockNlpEntityService: Partial> = +// { +// getNlpMap: jest.fn().mockResolvedValue(mockNlpCacheMap), +// }; describe('BlockService', () => { let blockRepository: BlockRepository; @@ -112,6 +100,7 @@ describe('BlockService', () => { let hasPreviousBlocks: Block; let contentService: ContentService; let contentTypeService: ContentTypeService; + let nlpEntityService: NlpEntityService; beforeAll(async () => { const { getMocks } = await buildTestingMocks({ @@ -149,10 +138,7 @@ describe('BlockService', () => { NlpEntityRepository, NlpValueRepository, NlpSampleEntityRepository, - { - provide: NlpEntityService, // Mocking NlpEntityService - useValue: mockNlpEntityService, - }, + NlpEntityService, { provide: NlpValueService, useValue: {}, @@ -196,12 +182,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({ @@ -374,6 +362,9 @@ describe('BlockService', () => { describe('matchBestNLP', () => { const nlpPenaltyFactor = 2; 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 matchedPatterns = [mockNlpPatternsSetOne, mockNlpPatternsSetTwo]; const nlp = mockNlpEntitiesSetOne; @@ -399,6 +390,9 @@ 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 matchedPatterns = [mockNlpPatternsSetOne, mockNlpPatternsSetThree]; const nlp = mockNlpEntitiesSetOne; @@ -424,6 +418,9 @@ describe('BlockService', () => { }); it('should return undefined if no blocks match or the list is empty', async () => { + jest + .spyOn(nlpEntityService, 'getNlpMap') + .mockResolvedValue(mockNlpCacheMap); const blocks: Block[] = []; // Empty block array const matchedPatterns: NlpPattern[][] = []; const nlp = mockNlpEntitiesSetOne; @@ -443,18 +440,16 @@ describe('BlockService', () => { describe('calculateBlockScore', () => { const nlpPenaltyFactor = 0.9; it('should calculate the correct NLP score for a block', async () => { - const nlpCacheMap: NlpCacheMap = new Map(); - const score = await blockService.calculateBlockScore( mockNlpPatternsSetOne, mockNlpEntitiesSetOne, - nlpCacheMap, + mockNlpCacheMap, nlpPenaltyFactor, ); const score2 = await blockService.calculateBlockScore( mockNlpPatternsSetTwo, mockNlpEntitiesSetOne, - nlpCacheMap, + mockNlpCacheMap, nlpPenaltyFactor, ); @@ -464,18 +459,16 @@ describe('BlockService', () => { }); it('should calculate the correct NLP score for a block and apply penalties ', async () => { - const nlpCacheMap: NlpCacheMap = new Map(); - const score = await blockService.calculateBlockScore( mockNlpPatternsSetOne, mockNlpEntitiesSetOne, - nlpCacheMap, + mockNlpCacheMap, nlpPenaltyFactor, ); const score2 = await blockService.calculateBlockScore( mockNlpPatternsSetThree, mockNlpEntitiesSetOne, - nlpCacheMap, + mockNlpCacheMap, nlpPenaltyFactor, ); @@ -495,44 +488,6 @@ describe('BlockService', () => { expect(score).toBe(0); // No matching entity, so score should be 0 }); - it('should correctly use entity cache to avoid redundant database calls', async () => { - const nlpCacheMap: NlpCacheMap = new Map(); - - // Spy on findAndPopulate - const findAndPopulateSpy = jest.spyOn( - mockNlpEntityService, - 'findAndPopulate', - ); - - // First call: should trigger findAndPopulate and cache results - await blockService.calculateBlockScore( - mockNlpPatternsSetOne, - mockNlpEntitiesSetOne, - nlpCacheMap, - nlpPenaltyFactor, - ); - - const cacheSizeAfterFirstCall = nlpCacheMap.size; - const callsAfterFirstCall = findAndPopulateSpy.mock.calls.length; - - // should not call findAndPopulate again since data is cached - await blockService.calculateBlockScore( - mockNlpPatternsSetOne, - mockNlpEntitiesSetOne, - nlpCacheMap, - nlpPenaltyFactor, - ); - - const cacheSizeAfterSecondCall = nlpCacheMap.size; - const callsAfterSecondCall = findAndPopulateSpy.mock.calls.length; - - expect(cacheSizeAfterSecondCall).toBe(cacheSizeAfterFirstCall); - expect(callsAfterSecondCall).toBe(callsAfterFirstCall); // No new call - expect(findAndPopulateSpy).toHaveBeenCalledTimes(1); // Should only be called once - - // Cleanup - findAndPopulateSpy.mockRestore(); - }); }); describe('matchPayload', () => { diff --git a/api/src/chat/services/block.service.ts b/api/src/chat/services/block.service.ts index 6701f35e..af42e79e 100644 --- a/api/src/chat/services/block.service.ts +++ b/api/src/chat/services/block.service.ts @@ -16,7 +16,6 @@ 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 { NlpCacheMap } from '@/nlp/schemas/types'; import { NlpEntityService } from '@/nlp/services/nlp-entity.service'; import { PluginService } from '@/plugins/plugins.service'; @@ -39,6 +38,7 @@ import { StdOutgoingSystemEnvelope, } from '../schemas/types/message'; import { + isNlpPattern, NlpPattern, NlpPatternMatchResult, PayloadPattern, @@ -395,15 +395,25 @@ export class BlockService extends BaseService< let bestBlock: Block | BlockFull | undefined; let highestScore = 0; - - const nlpCacheMap: NlpCacheMap = new Map(); - + const entityNames: string[] = blocks.flatMap((block) => + block.patterns.flatMap((patternGroup) => { + if (Array.isArray(patternGroup)) { + return patternGroup.flatMap((pattern) => + isNlpPattern(pattern) ? [pattern.entity] : [], + ); + } + return []; // Skip non-array patternGroups + }), + ); + const uniqueEntityNames: string[] = [...new Set(entityNames)]; + const nlpCacheMap: NlpCacheMap = + await this.entityService.getNlpMap(uniqueEntityNames); // Iterate through all blocks and calculate their NLP score for (let i = 0; i < blocks.length; i++) { const block = blocks[i]; const patterns = matchedPatterns[i]; // If compatible, calculate the NLP score for this block - const nlpScore = await this.calculateBlockScore( + const nlpScore: number = await this.calculateBlockScore( patterns, nlp, nlpCacheMap, @@ -432,7 +442,7 @@ 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 store and reuse fetched entity metadata (e.g., weights and valid values). + * @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. */ @@ -442,40 +452,15 @@ export class BlockService extends BaseService< nlpCacheMap: NlpCacheMap, nlpPenaltyFactor: number, ): Promise { - let nlpScore = 0; - - // Collect all unique entity names from patterns - const entityNames = [...new Set(patterns.map((pattern) => pattern.entity))]; - - // Check the cache for existing lookups first - const uncachedEntityNames = entityNames.filter( - (name) => !nlpCacheMap.has(name), - ); - // Fetch only uncached entities in one query, with values already populated - const entityLookups: NlpEntityFull[] = uncachedEntityNames.length - ? await this.entityService.findAndPopulate({ - name: { $in: uncachedEntityNames }, - }) - : []; - - // Populate the cache with the fetched lookups - entityLookups.forEach((lookup) => { - nlpCacheMap.set(lookup.name, { - id: lookup.id, - weight: lookup.weight, - values: lookup.values?.map((v) => v.value) ?? [], - }); - }); - - // Calculate the score for each pattern - const patternScores = patterns.map((pattern) => { + // 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 = nlp.entities.find( + const matchedEntity: NLU.ParseEntity | undefined = nlp.entities.find( (e) => e.entity === pattern.entity && - entityData.values.includes(e.value) && + entityData?.values.some((v) => v === e.value) && (pattern.match !== 'value' || e.value === pattern.value), ); @@ -486,10 +471,8 @@ export class BlockService extends BaseService< : 0; }); - // Sum up all scores - nlpScore = patternScores.reduce((sum, score) => sum + score, 0); - - return nlpScore; + // 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 b7dcf3a2..0a659648 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([ 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/services/nlp-entity.service.spec.ts b/api/src/nlp/services/nlp-entity.service.spec.ts index 6b76e809..04b0fc77 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] = @@ -221,4 +232,58 @@ 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 firstMockLookup = { + name: 'intent', + ...firstMockValues, + values: [{ value: 'buy' }, { value: 'sell' }], + } as unknown as Partial; + const secondMockValues = { + id: '2', + weight: 5, + }; + const secondMockLook = { + name: 'subject', + ...secondMockValues, + values: [{ value: 'product' }], + } as unknown as Partial; + const mockLookups = [firstMockLookup, secondMockLook]; + + const entityNames = ['intent', 'subject']; + + // Mock findAndPopulate + jest + .spyOn(nlpEntityService, 'findAndPopulate') + .mockResolvedValue(mockLookups as unknown as NlpEntityFull[]); + + // Act + const result = await nlpEntityService.getNlpMap(entityNames); + + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(2); + expect(result.get('intent')).toEqual({ + ...firstMockValues, + values: ['buy', 'sell'], + }); + expect(result.get('subject')).toEqual({ + ...secondMockValues, + values: ['product'], + }); + }); + + it('should return an empty map if no lookups are found', async () => { + jest.spyOn(nlpEntityService, 'findAndPopulate').mockResolvedValue([]); + + const result = await nlpEntityService.getNlpMap(['nonexistent']); + + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(0); + }); + }); }); diff --git a/api/src/nlp/services/nlp-entity.service.ts b/api/src/nlp/services/nlp-entity.service.ts index 6313f8b2..1307bacd 100644 --- a/api/src/nlp/services/nlp-entity.service.ts +++ b/api/src/nlp/services/nlp-entity.service.ts @@ -6,8 +6,13 @@ * 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); @@ -119,4 +125,55 @@ export class NlpEntityService extends BaseService< ); return Promise.all(findOrCreate); } + + /** + * Clears the NLP map cache + */ + async clearCache() { + 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(entityNames: string[]): Promise { + const lookups = await this.findAndPopulate({ name: { $in: entityNames } }); + const map: NlpCacheMap = new Map(); + for (const lookup of lookups) { + map.set(lookup.name, { + id: lookup.id, + weight: lookup.weight, + values: lookup.values?.map((v) => v.value) ?? [], + }); + } + + return 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/mocks/nlp.ts b/api/src/utils/test/mocks/nlp.ts index 04c1742d..a88b3bbd 100644 --- a/api/src/utils/test/mocks/nlp.ts +++ b/api/src/utils/test/mocks/nlp.ts @@ -7,6 +7,7 @@ */ import { NLU } from '@/helper/types'; +import { NlpCacheMap } from '@/nlp/schemas/types'; export const nlpEntitiesGreeting: NLU.ParseEntities = { entities: [ @@ -57,3 +58,22 @@ export const mockNlpEntitiesSetTwo: NLU.ParseEntities = { }, ], }; + +export const mockNlpCacheMap: NlpCacheMap = new Map([ + [ + 'intent', + { + id: '67e3e41eff551ca5be70559c', + weight: 1, + values: ['greeting', 'affirmation'], + }, + ], + [ + 'firstname', + { + id: '67e3e41eff551ca5be70559d', + weight: 1, + values: ['jhon', 'doe'], + }, + ], +]);