feat: refine caching mechanisms

This commit is contained in:
MohamedAliBouhaouala
2025-04-24 18:57:45 +01:00
parent 34f6baa505
commit 1941c54bc3
11 changed files with 232 additions and 109 deletions

View File

@@ -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'
);
}

View File

@@ -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<Record<keyof NlpEntityService, jest.Mock>> =
{
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<Record<keyof NlpEntityService, jest.Mock>> =
// {
// 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', () => {

View File

@@ -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<number> {
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);
}
/**

View File

@@ -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([

View File

@@ -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([

View File

@@ -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<NlpEntityFull>;
const secondMockValues = {
id: '2',
weight: 5,
};
const secondMockLook = {
name: 'subject',
...secondMockValues,
values: [{ value: 'product' }],
} as unknown as Partial<NlpEntityFull>;
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);
});
});
});

View File

@@ -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<NlpCacheMap> {
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;
}
}

View File

@@ -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(),
},
},
],
});
[

View File

@@ -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(),
},
},
],
});
[

View File

@@ -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';

View File

@@ -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'],
},
],
]);