feat: refactor current implementation

This commit is contained in:
MohamedAliBouhaouala 2025-05-06 15:20:53 +01:00
parent 1281c9e23b
commit 392649f08a
5 changed files with 163 additions and 211 deletions

View File

@ -37,7 +37,6 @@ 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 { NlpCacheMap } from '@/nlp/schemas/types';
import { NlpEntityService } from '@/nlp/services/nlp-entity.service';
import { NlpValueService } from '@/nlp/services/nlp-value.service';
import { PluginService } from '@/plugins/plugins.service';
@ -53,6 +52,8 @@ import {
blockProductListMock,
blocks,
mockModifiedNlpBlock,
mockModifiedNlpBlockOne,
mockModifiedNlpBlockTwo,
mockNlpBlock,
mockNlpPatternsSetOne,
mockNlpPatternsSetThree,
@ -79,7 +80,6 @@ import { Category, CategoryModel } from '../schemas/category.schema';
import { LabelModel } from '../schemas/label.schema';
import { FileType } from '../schemas/types/attachment';
import { StdOutgoingListMessage } from '../schemas/types/message';
import { NlpPattern } from '../schemas/types/pattern';
import { CategoryRepository } from './../repositories/category.repository';
import { BlockService } from './block.service';
@ -324,55 +324,50 @@ 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', () => {
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;
// Spy on calculateBlockScore to check if it's called
const calculateBlockScoreSpy = jest.spyOn(
blockService,
'calculateBlockScore',
);
const bestBlock = await blockService.matchBestNLP(
blocks,
matchedPatterns,
nlp,
nlpPenaltyFactor,
);
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
@ -388,19 +383,13 @@ describe('BlockService', () => {
.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;
// Spy on calculateBlockScore to check if it's called
const calculateBlockScoreSpy = jest.spyOn(
blockService,
'calculateBlockScore',
);
const bestBlock = await blockService.matchBestNLP(
blocks,
matchedPatterns,
nlp,
nlpPenaltyFactor,
);
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
@ -408,7 +397,29 @@ describe('BlockService', () => {
// Restore the spy after the test
calculateBlockScoreSpy.mockRestore();
// Assert that the block with the highest NLP score is selected
expect(bestBlock).toEqual(mockModifiedNlpBlock);
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 () => {
@ -416,15 +427,9 @@ describe('BlockService', () => {
.spyOn(nlpEntityService, 'getNlpMap')
.mockResolvedValue(mockNlpCacheMap);
const blocks: BlockFull[] = []; // Empty block array
const matchedPatterns: NlpPattern[][] = [];
const nlp = mockNlpEntitiesSetOne;
const bestBlock = await blockService.matchBestNLP(
blocks,
matchedPatterns,
nlp,
nlpPenaltyFactor,
);
const bestBlock = await blockService.matchBestNLP(blocks, nlp);
// Assert that undefined is returned when no blocks are available
expect(bestBlock).toBeUndefined();
@ -432,19 +437,17 @@ describe('BlockService', () => {
});
describe('calculateBlockScore', () => {
const nlpPenaltyFactor = 0.9;
it('should calculate the correct NLP score for a block', async () => {
const score = blockService.calculateBlockScore(
jest
.spyOn(nlpEntityService, 'getNlpMap')
.mockResolvedValue(mockNlpCacheMap);
const score = await blockService.calculateBlockScore(
mockNlpPatternsSetOne,
mockNlpEntitiesSetOne,
mockNlpCacheMap,
nlpPenaltyFactor,
);
const score2 = blockService.calculateBlockScore(
const score2 = await blockService.calculateBlockScore(
mockNlpPatternsSetTwo,
mockNlpEntitiesSetOne,
mockNlpCacheMap,
nlpPenaltyFactor,
);
expect(score).toBeGreaterThan(0);
@ -453,17 +456,16 @@ describe('BlockService', () => {
});
it('should calculate the correct NLP score for a block and apply penalties ', async () => {
const score = blockService.calculateBlockScore(
jest
.spyOn(nlpEntityService, 'getNlpMap')
.mockResolvedValue(mockNlpCacheMap);
const score = await blockService.calculateBlockScore(
mockNlpPatternsSetOne,
mockNlpEntitiesSetOne,
mockNlpCacheMap,
nlpPenaltyFactor,
);
const score2 = blockService.calculateBlockScore(
const score2 = await blockService.calculateBlockScore(
mockNlpPatternsSetThree,
mockNlpEntitiesSetOne,
mockNlpCacheMap,
nlpPenaltyFactor,
);
expect(score).toBeGreaterThan(0);
@ -472,12 +474,10 @@ describe('BlockService', () => {
});
it('should return 0 if no matching entities are found', async () => {
const nlpCacheMap: NlpCacheMap = new Map();
const score = blockService.calculateBlockScore(
jest.spyOn(nlpEntityService, 'getNlpMap').mockResolvedValue(new Map());
const score = await blockService.calculateBlockScore(
mockNlpPatternsSetTwo,
mockNlpEntitiesSetOne,
nlpCacheMap,
nlpPenaltyFactor,
);
expect(score).toBe(0); // No matching entity, so score should be 0

View File

@ -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 { NlpCacheMap, NlpCacheMapValues } from '@/nlp/schemas/types';
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';
@ -37,12 +37,7 @@ import {
StdOutgoingEnvelope,
StdOutgoingSystemEnvelope,
} from '../schemas/types/message';
import {
isNlpPattern,
NlpPattern,
NlpPatternMatchResult,
PayloadPattern,
} from '../schemas/types/pattern';
import { NlpPattern, PayloadPattern } from '../schemas/types/pattern';
import { Payload, StdQuickReply } from '../schemas/types/quick-reply';
import { SubscriberContext } from '../schemas/types/subscriberContext';
@ -200,36 +195,7 @@ export class BlockService extends BaseService<
// 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.
const matchesWithPatterns: NlpPatternMatchResult[] =
filteredBlocks.reduce<NlpPatternMatchResult[]>((acc, b) => {
const matchedPattern = this.matchNLP(nlp, b);
if (matchedPattern && matchedPattern.length > 0) {
acc.push({ block: b, matchedPattern });
}
return acc;
}, []);
// @TODO Make nluPenaltyFactor configurable in UI settings
const nluPenaltyFactor = 0.95;
// Log the matched patterns
this.logger.debug(
`Matched patterns: ${JSON.stringify(matchesWithPatterns.map((p) => p.matchedPattern))}`,
);
// Proceed with matching the best NLP block
if (matchesWithPatterns.length > 0) {
const matchedBlocks = matchesWithPatterns.map((m) => m.block);
const matchedPatterns = matchesWithPatterns.map(
(p) => p.matchedPattern,
);
block = await this.matchBestNLP(
matchedBlocks,
matchedPatterns,
nlp,
nluPenaltyFactor,
);
}
block = await this.matchBestNLP(filteredBlocks, nlp);
}
}
@ -342,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;
@ -355,8 +321,9 @@ export class BlockService extends BaseService<
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) => {
@ -375,59 +342,44 @@ export class BlockService extends BaseService<
}
/**
* Selects the best-matching block based on NLP pattern scoring.
* Matches the provided NLU parsed entities with patterns in a set of blocks and returns
* the block with the highest matching score.
*
* This function evaluates each block by calculating a score derived from its matched NLP patterns,
* the parsed NLP entities, and a penalty factor. It compares the scores across all blocks and
* returns the one with the highest calculated 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 blocks - An array of candidate blocks to evaluate.
* @param matchedPatterns - A two-dimensional array of matched NLP patterns corresponding to each block.
* @param nlp - The parsed NLP entities used for scoring.
* @param nlpPenaltyFactor - A numeric penalty factor applied during scoring to influence block selection.
* @returns The block with the highest NLP score, or undefined if no valid block is found.
* @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<BlockFull | undefined>} - A promise that resolves to the BlockFull
* with the highest match score, or undefined if no matches are found.
*/
async matchBestNLP(
blocks: BlockFull[],
matchedPatterns: NlpPattern[][],
nlp: NLU.ParseEntities,
nlpPenaltyFactor: number,
): Promise<BlockFull | undefined> {
if (!blocks || blocks.length === 0) return undefined;
if (blocks.length === 1) return blocks[0];
const scoredBlocks = await Promise.all(
blocks.map(async (block) => {
const matchedPatterns = this.matchNLP(nlp, block) || [];
let bestBlock: BlockFull | undefined;
let highestScore = 0;
const entityNames = this.extractNlpEntityNames(blocks);
const uniqueEntityNames = [...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 = this.calculateBlockScore(
patterns,
nlp,
nlpCacheMap,
nlpPenaltyFactor,
);
if (nlpScore > highestScore) {
highestScore = nlpScore;
bestBlock = block;
}
}
const scores = await Promise.all(
matchedPatterns.map((pattern) =>
this.calculateBlockScore(pattern, nlp),
),
);
if (bestBlock) {
this.logger.debug(`Best NLP score obtained: ${highestScore}`);
this.logger.debug(`Best block selected:`, {
id: bestBlock.id,
name: bestBlock.name,
});
}
const maxScore = scores.length > 0 ? Math.max(...scores) : 0;
return bestBlock;
return { block, score: maxScore };
}),
);
const best = scoredBlocks.reduce(
(acc, curr) => (curr.score > acc.score ? curr : acc),
{ block: undefined, score: 0 },
);
return best.block;
}
/**
@ -445,13 +397,15 @@ export class BlockService extends BaseService<
* @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.
*/
calculateBlockScore(
async calculateBlockScore(
patterns: NlpPattern[],
nlp: NLU.ParseEntities,
nlpCacheMap: NlpCacheMap,
nlpPenaltyFactor: number,
): number {
): Promise<number> {
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);
@ -465,7 +419,7 @@ export class BlockService extends BaseService<
matchedEntity,
pattern,
entityData,
nlpPenaltyFactor,
nluPenaltyFactor,
);
});
@ -473,28 +427,6 @@ export class BlockService extends BaseService<
return patternScores.reduce((sum, score) => sum + score, 0);
}
/**
* Extracts the names of NLP entities from a given list of blocks.
* This method recursively goes through each block, pattern group, and pattern,
* filtering for valid NLP patterns and extracting the `entity` field.
* The resulting array contains the names of all the NLP entities found across all patterns.
*
* @param blocks - An array of `BlockFull` objects containing patterns.
* @returns An array of NLP entity names as strings.
*/
private extractNlpEntityNames(blocks: BlockFull[]): string[] {
return blocks.flatMap((block) =>
block.patterns.flatMap((patternGroup) => {
if (Array.isArray(patternGroup)) {
return patternGroup.flatMap((pattern) =>
isNlpPattern(pattern) ? [pattern.entity] : [],
);
}
return []; // Skip non-array patternGroups
}),
);
}
/**
* 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.

View File

@ -239,7 +239,7 @@ describe('nlpEntityService', () => {
id: '1',
weight: 1,
};
const firstMockLookup = {
const firstMockEntity = {
name: 'intent',
...firstMockValues,
values: [{ value: 'buy' }, { value: 'sell' }],
@ -248,42 +248,31 @@ describe('nlpEntityService', () => {
id: '2',
weight: 5,
};
const secondMockLook = {
const secondMockEntity = {
name: 'subject',
...secondMockValues,
values: [{ value: 'product' }],
} as unknown as Partial<NlpEntityFull>;
const mockLookups = [firstMockLookup, secondMockLook];
const entityNames = ['intent', 'subject'];
const mockEntities = [firstMockEntity, secondMockEntity];
// Mock findAndPopulate
jest
.spyOn(nlpEntityService, 'findAndPopulate')
.mockResolvedValue(mockLookups as unknown as NlpEntityFull[]);
.spyOn(nlpEntityService, 'findAllAndPopulate')
.mockResolvedValue(mockEntities as unknown as NlpEntityFull[]);
// Act
const result = await nlpEntityService.getNlpMap(entityNames);
const result = await nlpEntityService.getNlpMap();
expect(result).toBeInstanceOf(Map);
expect(result.size).toBe(2);
expect(result.get('intent')).toEqual({
...firstMockValues,
values: ['buy', 'sell'],
name: 'intent',
...firstMockEntity,
});
expect(result.get('subject')).toEqual({
...secondMockValues,
values: ['product'],
name: 'subject',
...secondMockEntity,
});
});
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

@ -163,17 +163,11 @@ export class NlpEntityService extends BaseService<
* @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;
async getNlpMap(): Promise<NlpCacheMap> {
const entities = await this.findAllAndPopulate();
return entities.reduce((acc, curr) => {
acc.set(curr.name, curr);
return acc;
}, new Map());
}
}

View File

@ -291,22 +291,22 @@ export const mockNlpBlock: BlockFull = {
'Hello',
'/we*lcome/',
{ label: 'Mock Nlp', value: 'MOCK_NLP' },
mockNlpPatternsSetOne,
[
...mockNlpPatternsSetOne,
[
{
entity: 'intent',
match: 'value',
value: 'greeting',
},
{
entity: 'firstname',
match: 'value',
value: 'doe',
},
],
{
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;
@ -318,12 +318,49 @@ export const mockModifiedNlpBlock: BlockFull = {
'Hello',
'/we*lcome/',
{ label: 'Modified Mock Nlp', value: 'MODIFIED_MOCK_NLP' },
[...mockNlpPatternsSetThree],
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',
[