mirror of
https://github.com/hexastack/hexabot
synced 2025-06-26 18:27:28 +00:00
fix: enhance implementation
This commit is contained in:
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<E extends NLU.ParseEntities, B extends BlockStub>(
|
||||
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<BlockFull | undefined>} - 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<BlockFull | undefined> {
|
||||
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<B extends BlockStub>(
|
||||
blocks: B[],
|
||||
scoredEntities: NLU.ScoredEntities,
|
||||
): Promise<B | undefined> {
|
||||
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<number> {
|
||||
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<E extends NLU.ParseEntity>(
|
||||
{ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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<any, any>) {
|
||||
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
|
||||
*
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Record<string, Language>> {
|
||||
const languages = await this.findAll();
|
||||
return languages.reduce((acc, curr) => {
|
||||
return {
|
||||
|
||||
@@ -91,7 +91,7 @@ describe('NlpEntityRepository', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPageAndPopulate', () => {
|
||||
describe('findAndPopulate', () => {
|
||||
it('should return all nlp entities with populate', async () => {
|
||||
const pageQuery = getPageQuery<NlpEntity>({
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -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<NlpValue>({
|
||||
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<NlpValueFull>[],
|
||||
);
|
||||
expect(result).toEqualPayload(nlpValueFixturesWithEntities);
|
||||
expect(result).toEqualPayload(nlpValueFixturesWithEntities, [
|
||||
'id',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'metadata',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -98,25 +98,33 @@ describe('NlpValueService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPageAndPopulate', () => {
|
||||
it('should return all nlp entities with populate', async () => {
|
||||
const pageQuery = getPageQuery<NlpValue>({ sort: ['value', 'desc'] });
|
||||
const result = await nlpValueService.findPageAndPopulate({}, pageQuery);
|
||||
describe('findAndPopulate', () => {
|
||||
it('should return all nlp values with populate', async () => {
|
||||
const pageQuery = getPageQuery<NlpValue>({ 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<NlpValueFull, keyof BaseSchema>[],
|
||||
);
|
||||
expect(result).toEqualPayload(nlpValueFixturesWithEntities);
|
||||
expect(result).toEqualPayload(nlpValueFixturesWithEntities, [
|
||||
'id',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'metadata',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
134
api/src/nlp/services/nlp.service.spec.ts
Normal file
134
api/src/nlp/services/nlp.service.spec.ts
Normal file
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<NLU.ScoredEntities> {
|
||||
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.
|
||||
*
|
||||
|
||||
4
api/src/utils/test/fixtures/nlpentity.ts
vendored
4
api/src/utils/test/fixtures/nlpentity.ts
vendored
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
24
api/src/utils/test/fixtures/nlpvalue.ts
vendored
24
api/src/utils/test/fixtures/nlpvalue.ts
vendored
@@ -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 };
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user