fix(api): resolve file conflicts

This commit is contained in:
yassinedorbozgithub 2025-06-18 17:19:31 +01:00
commit 6438ccda3c
19 changed files with 607 additions and 468 deletions

View File

@ -24,14 +24,11 @@ export class CategoryRepository extends BaseRepository<
never, never,
CategoryDto CategoryDto
> { > {
private readonly blockService: BlockService;
constructor( constructor(
@InjectModel(Category.name) readonly model: Model<Category>, @InjectModel(Category.name) readonly model: Model<Category>,
@Optional() blockService?: BlockService, @Optional() private readonly blockService?: BlockService,
) { ) {
super(model, Category); super(model, Category);
this.blockService = blockService!;
} }
/** /**
@ -42,7 +39,7 @@ export class CategoryRepository extends BaseRepository<
* @param criteria - The filter criteria for finding blocks to delete. * @param criteria - The filter criteria for finding blocks to delete.
*/ */
async preDelete( async preDelete(
query: Query< _query: Query<
DeleteResult, DeleteResult,
Document<Category, any, any>, Document<Category, any, any>,
unknown, unknown,
@ -51,23 +48,18 @@ export class CategoryRepository extends BaseRepository<
>, >,
criteria: TFilterQuery<Category>, criteria: TFilterQuery<Category>,
) { ) {
criteria = query.getQuery(); if (criteria._id) {
const ids = Array.isArray(criteria._id?.$in) const block = await this.blockService?.findOneAndPopulate({
? criteria._id.$in category: criteria._id,
: Array.isArray(criteria._id)
? criteria._id
: [criteria._id];
for (const id of ids) {
const associatedBlocks = await this.blockService.findOne({
category: id,
}); });
if (associatedBlocks) {
const category = await this.findOne({ _id: id }); if (block) {
throw new ForbiddenException( throw new ForbiddenException(
`Category ${category?.label || id} has blocks associated with it`, `Category ${block.category?.label} has at least one associated block`,
); );
} }
} else {
throw new Error('Attempted to delete category using unknown criteria');
} }
} }
} }

View File

@ -8,16 +8,14 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose'; import { InjectModel } from '@nestjs/mongoose';
import { Document, Model, Query } from 'mongoose'; import { Model } from 'mongoose';
import { BaseRepository, DeleteResult } from '@/utils/generics/base-repository'; import { BaseRepository } from '@/utils/generics/base-repository';
import { TFilterQuery } from '@/utils/types/filter.types';
import { LabelDto } from '../dto/label.dto'; import { LabelDto } from '../dto/label.dto';
import { import {
Label, Label,
LABEL_POPULATE, LABEL_POPULATE,
LabelDocument,
LabelFull, LabelFull,
LabelPopulate, LabelPopulate,
} from '../schemas/label.schema'; } from '../schemas/label.schema';
@ -32,59 +30,4 @@ export class LabelRepository extends BaseRepository<
constructor(@InjectModel(Label.name) readonly model: Model<Label>) { constructor(@InjectModel(Label.name) readonly model: Model<Label>) {
super(model, Label, LABEL_POPULATE, LabelFull); super(model, Label, LABEL_POPULATE, LabelFull);
} }
/**
* After creating a `Label`, this method emits an event and updates the `label_id` field.
*
* @param created - The created label document instance.
*
* @returns A promise that resolves when the update operation is complete.
*/
async postCreate(created: LabelDocument): Promise<void> {
this.eventEmitter.emit(
'hook:label:create',
created,
async (result: Record<string, any>) => {
await this.model.updateOne(
{ _id: created._id },
{
$set: {
label_id: {
...(created.label_id || {}),
...result,
},
},
},
);
},
);
}
/**
* Before deleting a label, this method fetches the label(s) based on the given criteria and emits a delete event.
*
* @param query - The Mongoose query object used for deletion.
* @param criteria - The filter criteria for finding the labels to be deleted.
*
* @returns {Promise<void>} A promise that resolves once the event is emitted.
*/
async preDelete(
_query: Query<
DeleteResult,
Document<Label, any, any>,
unknown,
Label,
'deleteOne' | 'deleteMany'
>,
_criteria: TFilterQuery<Label>,
): Promise<void> {
const ids = Array.isArray(_criteria._id?.$in)
? _criteria._id.$in
: Array.isArray(_criteria._id)
? _criteria._id
: [_criteria._id];
const labels = await this.find({ _id: { $in: ids } });
this.eventEmitter.emit('hook:label:delete', labels);
}
} }

View File

@ -22,6 +22,7 @@ import { SettingService } from '@/setting/services/setting.service';
import { FALLBACK_DEFAULT_NLU_PENALTY_FACTOR } from '@/utils/constants/nlp'; import { FALLBACK_DEFAULT_NLU_PENALTY_FACTOR } from '@/utils/constants/nlp';
import { BaseService } from '@/utils/generics/base-service'; import { BaseService } from '@/utils/generics/base-service';
import { getRandomElement } from '@/utils/helpers/safeRandom'; import { getRandomElement } from '@/utils/helpers/safeRandom';
import { TFilterQuery } from '@/utils/types/filter.types';
import { getDefaultFallbackOptions } from '../constants/block'; import { getDefaultFallbackOptions } from '../constants/block';
import { BlockDto } from '../dto/block.dto'; import { BlockDto } from '../dto/block.dto';
@ -790,28 +791,31 @@ export class BlockService extends BaseService<
/** /**
* Updates the `trigger_labels` and `assign_labels` fields of a block when a label is deleted. * Updates the `trigger_labels` and `assign_labels` fields of a block when a label is deleted.
* *
* * @param _query - The Mongoose query object used for deletion.
* This method removes the deleted label from the `trigger_labels` and `assign_labels` fields of all blocks that have the label. * @param criteria - The filter criteria for finding the labels to be deleted.
*
* @param label The label that is being deleted.
*/ */
@OnEvent('hook:label:delete') @OnEvent('hook:label:preDelete')
async handleLabelDelete(labels: Label[]) { async handleLabelPreDelete(
const blocks = await this.find({ _query: unknown,
$or: [ criteria: TFilterQuery<Label>,
{ trigger_labels: { $in: labels.map((l) => l.id) } }, ): Promise<void> {
{ assign_labels: { $in: labels.map((l) => l.id) } }, if (criteria._id) {
], await this.getRepository().model.updateMany(
}); {
$or: [
for (const block of blocks) { { trigger_labels: criteria._id },
const trigger_labels = block.trigger_labels.filter( { assign_labels: criteria._id },
(labelId) => !labels.find((l) => l.id === labelId), ],
},
{
$pull: {
trigger_labels: criteria._id,
assign_labels: criteria._id,
},
},
); );
const assign_labels = block.assign_labels.filter( } else {
(labelId) => !labels.find((l) => l.id === labelId), throw new Error('Attempted to delete label using unknown criteria');
);
await this.updateOne(block.id, { trigger_labels, assign_labels });
} }
} }
} }

View File

@ -86,7 +86,7 @@ describe('BotService', () => {
]); ]);
}); });
afterEach(jest.clearAllMocks); afterEach(jest.resetAllMocks);
afterAll(closeInMongodConnection); afterAll(closeInMongodConnection);
describe('startConversation', () => { describe('startConversation', () => {
afterAll(() => { afterAll(() => {

View File

@ -11,6 +11,8 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { BotStatsType } from '@/analytics/schemas/bot-stats.schema'; import { BotStatsType } from '@/analytics/schemas/bot-stats.schema';
import EventWrapper from '@/channel/lib/EventWrapper'; import EventWrapper from '@/channel/lib/EventWrapper';
import { HelperService } from '@/helper/helper.service';
import { FlowEscape, HelperType } from '@/helper/types';
import { LoggerService } from '@/logger/logger.service'; import { LoggerService } from '@/logger/logger.service';
import { SettingService } from '@/setting/services/setting.service'; import { SettingService } from '@/setting/services/setting.service';
@ -24,7 +26,7 @@ import {
OutgoingMessageFormat, OutgoingMessageFormat,
StdOutgoingMessageEnvelope, StdOutgoingMessageEnvelope,
} from '../schemas/types/message'; } from '../schemas/types/message';
import { BlockOptions, FallbackOptions } from '../schemas/types/options'; import { FallbackOptions } from '../schemas/types/options';
import { BlockService } from './block.service'; import { BlockService } from './block.service';
import { ConversationService } from './conversation.service'; import { ConversationService } from './conversation.service';
@ -39,6 +41,7 @@ export class BotService {
private readonly conversationService: ConversationService, private readonly conversationService: ConversationService,
private readonly subscriberService: SubscriberService, private readonly subscriberService: SubscriberService,
private readonly settingService: SettingService, private readonly settingService: SettingService,
private readonly helperService: HelperService,
) {} ) {}
/** /**
@ -244,10 +247,12 @@ export class BotService {
/** /**
* Handles advancing the conversation to the specified *next* block. * Handles advancing the conversation to the specified *next* block.
* *
* 1. Updates popular blocks stats. * @param convo - The current conversation object containing context and state.
* 2. Persists the updated conversation context. * @param next - The next block to proceed to in the conversation flow.
* 3. Triggers the next block. * @param event - The incoming event that triggered the conversation flow.
* 4. Ends the conversation if an unrecoverable error occurs. * @param fallback - Boolean indicating if this is a fallback response in case no appropriate reply was found.
*
* @returns A promise that resolves to a boolean indicating whether the next block was successfully triggered.
*/ */
async proceedToNextBlock( async proceedToNextBlock(
convo: ConversationFull, convo: ConversationFull,
@ -258,10 +263,7 @@ export class BotService {
// Increment stats about popular blocks // Increment stats about popular blocks
this.eventEmitter.emit('hook:stats:entry', BotStatsType.popular, next.name); this.eventEmitter.emit('hook:stats:entry', BotStatsType.popular, next.name);
this.logger.debug( this.logger.debug(
'Proceeding to next block ', `Proceeding to next block ${next.id} for conversation ${convo.id}`,
next.id,
' for conversation ',
convo.id,
); );
try { try {
@ -348,45 +350,16 @@ export class BotService {
) { ) {
try { try {
let fallback = false; let fallback = false;
const currentBlock = convo.current; this.logger.debug('Handling ongoing conversation message ...', convo.id);
const fallbackOptions: BlockOptions['fallback'] = convo.current?.options const matchedBlock = await this.findNextMatchingBlock(convo, event);
?.fallback
? convo.current.options.fallback
: {
active: false,
max_attempts: 0,
message: [],
};
// We will avoid having multiple matches when we are not at the start of a conversation
// and only if local fallback is enabled
const canHaveMultipleMatches = !fallbackOptions.active;
// Find the next block that matches
const nextBlocks = await this.blockService.findAndPopulate({
_id: { $in: convo.next.map(({ id }) => id) },
});
const matchedBlock = await this.blockService.match(
nextBlocks,
event,
canHaveMultipleMatches,
);
// If there is no match in next block then loopback (current fallback)
// This applies only to text messages + there's a max attempt to be specified
let fallbackBlock: BlockFull | undefined = undefined; let fallbackBlock: BlockFull | undefined = undefined;
if (!matchedBlock && this.shouldAttemptLocalFallback(convo, event)) { if (!matchedBlock && this.shouldAttemptLocalFallback(convo, event)) {
// Trigger block fallback const fallbackResult = await this.handleFlowEscapeFallback(
// NOTE : current is not populated, this may cause some anomaly convo,
fallbackBlock = { event,
...currentBlock, );
nextBlocks: convo.next, fallbackBlock = fallbackResult.nextBlock;
// If there's labels, they should be already have been assigned fallback = fallbackResult.fallback;
assign_labels: [],
trigger_labels: [],
attachedBlock: null,
category: null,
previousBlocks: [],
};
fallback = true;
} }
const next = matchedBlock || fallbackBlock; const next = matchedBlock || fallbackBlock;
@ -410,6 +383,91 @@ export class BotService {
} }
} }
/**
* Handles the flow escape fallback logic for a conversation.
*
* This method adjudicates the flow escape event and helps determine the next block to execute based on the helper's response.
* It can coerce the event to a specific next block, create a new context, or reprompt the user with a fallback message.
* If the helper cannot handle the flow escape, it returns a fallback block with the current conversation's state.
*
* @param convo - The current conversation object.
* @param event - The incoming event that triggered the fallback.
*
* @returns An object containing the next block to execute (if any) and a flag indicating if a fallback should occur.
*/
async handleFlowEscapeFallback(
convo: ConversationFull,
event: EventWrapper<any, any>,
): Promise<{ nextBlock?: BlockFull; fallback: boolean }> {
const currentBlock = convo.current;
const fallbackOptions: FallbackOptions =
this.blockService.getFallbackOptions(currentBlock);
const fallbackBlock: BlockFull = {
...currentBlock,
nextBlocks: convo.next,
assign_labels: [],
trigger_labels: [],
attachedBlock: null,
category: null,
previousBlocks: [],
};
try {
const helper = await this.helperService.getDefaultHelper(
HelperType.FLOW_ESCAPE,
);
if (!helper.canHandleFlowEscape(currentBlock)) {
return { nextBlock: fallbackBlock, fallback: true };
}
// Adjudicate the flow escape event
this.logger.debug(
`Adjudicating flow escape for block '${currentBlock.id}' in conversation '${convo.id}'.`,
);
const result = await helper.adjudicate(event, currentBlock);
switch (result.action) {
case FlowEscape.Action.COERCE: {
// Coerce the option to the next block
this.logger.debug(`Coercing option to the next block ...`, convo.id);
const proxiedEvent = new Proxy(event, {
get(target, prop, receiver) {
if (prop === 'getText') {
return () => result.coercedOption + '';
}
return Reflect.get(target, prop, receiver);
},
});
const matchedBlock = await this.findNextMatchingBlock(
convo,
proxiedEvent,
);
return { nextBlock: matchedBlock, fallback: false };
}
case FlowEscape.Action.NEW_CTX:
return { nextBlock: undefined, fallback: false };
case FlowEscape.Action.REPROMPT:
default:
if (result.repromptMessage) {
fallbackBlock.options.fallback = {
...fallbackOptions,
message: [result.repromptMessage],
};
}
return { nextBlock: fallbackBlock, fallback: true };
}
} catch (err) {
this.logger.warn(
'Unable to handle flow escape, using default local fallback ...',
err,
);
return { nextBlock: fallbackBlock, fallback: true };
}
}
/** /**
* Determines if the incoming message belongs to an active conversation and processes it accordingly. * Determines if the incoming message belongs to an active conversation and processes it accordingly.
* If an active conversation is found, the message is handled as part of that conversation. * If an active conversation is found, the message is handled as part of that conversation.

View File

@ -24,6 +24,7 @@ import {
} from '@/attachment/types'; } from '@/attachment/types';
import { config } from '@/config'; import { config } from '@/config';
import { BaseService } from '@/utils/generics/base-service'; import { BaseService } from '@/utils/generics/base-service';
import { TFilterQuery } from '@/utils/types/filter.types';
import { import {
SocketGet, SocketGet,
SocketPost, SocketPost,
@ -260,22 +261,23 @@ export class SubscriberService extends BaseService<
} }
/** /**
* Updates the `labels` field of a subscriber when a label is deleted. * Before deleting a `Label`, this method updates the `labels` field of a subscriber.
* *
* This method removes the deleted label from the `labels` field of all subscribers that have the label. * @param _query - The Mongoose query object used for deletion.
* * @param criteria - The filter criteria for finding the labels to be deleted.
* @param label The label that is being deleted.
*/ */
@OnEvent('hook:label:delete') @OnEvent('hook:label:preDelete')
async handleLabelDelete(labels: Label[]) { async handleLabelDelete(
const subscribers = await this.find({ _query: unknown,
labels: { $in: labels.map((l) => l.id) }, criteria: TFilterQuery<Label>,
}); ): Promise<void> {
for (const subscriber of subscribers) { if (criteria._id) {
const updatedLabels = subscriber.labels.filter( await this.getRepository().model.updateMany(
(label) => !labels.find((l) => l.id === label), { labels: criteria._id },
{ $pull: { labels: criteria._id } },
); );
await this.updateOne(subscriber.id, { labels: updatedLabels }); } else {
throw new Error('Attempted to delete label using unknown criteria');
} }
} }
} }

View File

@ -52,14 +52,15 @@ export class ContentTypeRepository extends BaseRepository<
>, >,
criteria: TFilterQuery<ContentType>, criteria: TFilterQuery<ContentType>,
) { ) {
const entityId: string = criteria._id as string;
const associatedBlocks = await this.blockService?.findOne({
'options.content.entity': entityId,
});
if (associatedBlocks) {
throw new ForbiddenException(`Content type have blocks associated to it`);
}
if (criteria._id) { if (criteria._id) {
const associatedBlock = await this.blockService?.findOne({
'options.content.entity': criteria._id,
});
if (associatedBlock) {
throw new ForbiddenException(
'Content type have blocks associated to it',
);
}
await this.contentModel.deleteMany({ entity: criteria._id }); await this.contentModel.deleteMany({ entity: criteria._id });
} else { } else {
throw new Error( throw new Error(

View File

@ -1,5 +1,5 @@
/* /*
* Copyright © 2024 Hexastack. All rights reserved. * Copyright © 2025 Hexastack. All rights reserved.
* *
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: * 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. * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@ -10,6 +10,7 @@ import { HttpModule } from '@nestjs/axios';
import { Global, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { InjectDynamicProviders } from 'nestjs-dynamic-providers'; import { InjectDynamicProviders } from 'nestjs-dynamic-providers';
import { CmsModule } from '@/cms/cms.module';
import { NlpModule } from '@/nlp/nlp.module'; import { NlpModule } from '@/nlp/nlp.module';
import { HelperController } from './helper.controller'; import { HelperController } from './helper.controller';
@ -25,7 +26,7 @@ import { HelperService } from './helper.service';
'dist/.hexabot/custom/extensions/helpers/**/*.helper.js', 'dist/.hexabot/custom/extensions/helpers/**/*.helper.js',
) )
@Module({ @Module({
imports: [HttpModule, NlpModule], imports: [HttpModule, NlpModule, CmsModule],
controllers: [HelperController], controllers: [HelperController],
providers: [HelperService], providers: [HelperService],
exports: [HelperService], exports: [HelperService],

View File

@ -36,7 +36,7 @@ export default abstract class BaseFlowEscapeHelper<
* @param _blockMessage - The block message to check. * @param _blockMessage - The block message to check.
* @returns - Whether the helper can handle the flow escape for the given block message. * @returns - Whether the helper can handle the flow escape for the given block message.
*/ */
abstract canHandleFlowEscape<T extends BlockStub>(_blockMessage: T): boolean; abstract canHandleFlowEscape<T extends BlockStub>(block: T): boolean;
/** /**
* Adjudicates the flow escape event. * Adjudicates the flow escape event.
@ -46,7 +46,7 @@ export default abstract class BaseFlowEscapeHelper<
* @returns - A promise that resolves to a FlowEscape.AdjudicationResult. * @returns - A promise that resolves to a FlowEscape.AdjudicationResult.
*/ */
abstract adjudicate<T extends BlockStub>( abstract adjudicate<T extends BlockStub>(
_event: EventWrapper<any, any>, event: EventWrapper<any, any>,
_block: T, block: T,
): Promise<FlowEscape.AdjudicationResult>; ): Promise<FlowEscape.AdjudicationResult>;
} }

View File

@ -6,6 +6,10 @@
* 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). * 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 LlmNluHelper from '@/extensions/helpers/llm-nlu/index.helper';
import { HelperService } from '@/helper/helper.service';
import { SettingService } from '@/setting/services/setting.service';
import { IGNORED_TEST_FIELDS } from '@/utils/test/constants';
import { nlpEntityFixtures } from '@/utils/test/fixtures/nlpentity'; import { nlpEntityFixtures } from '@/utils/test/fixtures/nlpentity';
import { installNlpValueFixtures } from '@/utils/test/fixtures/nlpvalue'; import { installNlpValueFixtures } from '@/utils/test/fixtures/nlpvalue';
import { getPageQuery } from '@/utils/test/pagination'; import { getPageQuery } from '@/utils/test/pagination';
@ -16,6 +20,8 @@ import {
import { buildTestingMocks } from '@/utils/test/utils'; import { buildTestingMocks } from '@/utils/test/utils';
import { NlpEntity } from '../schemas/nlp-entity.schema'; import { NlpEntity } from '../schemas/nlp-entity.schema';
import { NlpEntityService } from '../services/nlp-entity.service';
import { NlpService } from '../services/nlp.service';
import { NlpEntityRepository } from './nlp-entity.repository'; import { NlpEntityRepository } from './nlp-entity.repository';
import { NlpValueRepository } from './nlp-value.repository'; import { NlpValueRepository } from './nlp-value.repository';
@ -24,20 +30,42 @@ describe('NlpEntityRepository', () => {
let nlpEntityRepository: NlpEntityRepository; let nlpEntityRepository: NlpEntityRepository;
let nlpValueRepository: NlpValueRepository; let nlpValueRepository: NlpValueRepository;
let firstNameNlpEntity: NlpEntity | null; let firstNameNlpEntity: NlpEntity | null;
let nlpService: NlpService;
let llmNluHelper: LlmNluHelper;
let nlpEntityService: NlpEntityService;
beforeAll(async () => { beforeAll(async () => {
const { getMocks } = await buildTestingMocks({ const { getMocks, module } = await buildTestingMocks({
autoInjectFrom: ['providers'], autoInjectFrom: ['providers'],
imports: [rootMongooseTestModule(installNlpValueFixtures)], imports: [rootMongooseTestModule(installNlpValueFixtures)],
providers: [NlpEntityRepository], providers: [
NlpService,
LlmNluHelper,
{
provide: SettingService,
useValue: {
getSettings: jest.fn(() => ({
chatbot_settings: {
default_nlu_helper: 'llm-nlu-helper',
},
})),
},
},
],
}); });
[nlpEntityRepository, nlpValueRepository] = await getMocks([
NlpEntityRepository, [nlpEntityRepository, nlpValueRepository, nlpService, nlpEntityService] =
NlpValueRepository, await getMocks([
]); NlpEntityRepository,
NlpValueRepository,
NlpService,
NlpEntityService,
]);
firstNameNlpEntity = await nlpEntityRepository.findOne({ firstNameNlpEntity = await nlpEntityRepository.findOne({
name: 'firstname', name: 'firstname',
}); });
llmNluHelper = module.get(LlmNluHelper);
module.get(HelperService).register(llmNluHelper);
}); });
afterAll(closeInMongodConnection); afterAll(closeInMongodConnection);
@ -46,6 +74,12 @@ describe('NlpEntityRepository', () => {
describe('The deleteCascadeOne function', () => { describe('The deleteCascadeOne function', () => {
it('should delete a nlp entity', async () => { it('should delete a nlp entity', async () => {
nlpValueRepository.eventEmitter.once(
'hook:nlpEntity:preDelete',
async (...[query, criteria]) => {
await nlpService.handleEntityDelete(query, criteria);
},
);
const intentNlpEntity = await nlpEntityRepository.findOne({ const intentNlpEntity = await nlpEntityRepository.findOne({
name: 'intent', name: 'intent',
}); });
@ -97,4 +131,83 @@ describe('NlpEntityRepository', () => {
]); ]);
}); });
}); });
describe('postCreate', () => {
it('should create and attach a foreign_id to the newly created nlp entity', async () => {
nlpEntityRepository.eventEmitter.once(
'hook:nlpEntity:postCreate',
async (...[created]) => {
const helperSpy = jest.spyOn(llmNluHelper, 'addEntity');
jest.spyOn(nlpEntityService, 'updateOne');
await nlpService.handleEntityPostCreate(created);
expect(helperSpy).toHaveBeenCalledWith(created);
expect(nlpEntityService.updateOne).toHaveBeenCalledWith(
{
_id: created._id,
},
{ foreign_id: await helperSpy.mock.results[0].value },
);
},
);
const result = await nlpEntityRepository.create({
name: 'test1',
});
const intentNlpEntity = await nlpEntityRepository.findOne(result.id);
expect(intentNlpEntity?.foreign_id).toBeDefined();
expect(intentNlpEntity).toEqualPayload(result, [
...IGNORED_TEST_FIELDS,
'foreign_id',
]);
});
it('should not create and attach a foreign_id to the newly created nlp entity with builtin set to true', async () => {
nlpEntityRepository.eventEmitter.once(
'hook:nlpEntity:postCreate',
async (...[created]) => {
await nlpService.handleEntityPostCreate(created);
},
);
const result = await nlpEntityRepository.create({
name: 'test2',
builtin: true,
});
const nlpEntity = await nlpEntityRepository.findOne(result.id);
expect(nlpEntity?.foreign_id).toBeUndefined();
expect(nlpEntity).toEqualPayload(result);
});
});
describe('postUpdate', () => {
it('should update an NlpEntity and trigger a postUpdate event', async () => {
jest.spyOn(nlpService, 'handleEntityPostUpdate');
jest.spyOn(llmNluHelper, 'updateEntity');
nlpEntityRepository.eventEmitter.once(
'hook:nlpEntity:postUpdate',
async (...[query, updated]) => {
await nlpService.handleEntityPostUpdate(query, updated);
expect(llmNluHelper.updateEntity).toHaveBeenCalledWith(updated);
},
);
const updatedNlpEntity = await nlpEntityRepository.updateOne(
{
name: 'test2',
},
{ value: 'test3' },
);
expect(nlpService.handleEntityPostUpdate).toHaveBeenCalledTimes(1);
expect(llmNluHelper.updateEntity).toHaveBeenCalledTimes(1);
const result = await nlpEntityRepository.findOne(updatedNlpEntity.id);
expect(result).toEqualPayload(updatedNlpEntity);
});
});
}); });

View File

@ -8,23 +8,18 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose'; import { InjectModel } from '@nestjs/mongoose';
import { Document, Model, Query } from 'mongoose'; import { Model } from 'mongoose';
import { BaseRepository, DeleteResult } from '@/utils/generics/base-repository'; import { BaseRepository } from '@/utils/generics/base-repository';
import { TFilterQuery } from '@/utils/types/filter.types';
import { NlpEntityDto } from '../dto/nlp-entity.dto'; import { NlpEntityDto } from '../dto/nlp-entity.dto';
import { import {
NLP_ENTITY_POPULATE, NLP_ENTITY_POPULATE,
NlpEntity, NlpEntity,
NlpEntityDocument,
NlpEntityFull, NlpEntityFull,
NlpEntityPopulate, NlpEntityPopulate,
} from '../schemas/nlp-entity.schema'; } from '../schemas/nlp-entity.schema';
import { NlpSampleEntityRepository } from './nlp-sample-entity.repository';
import { NlpValueRepository } from './nlp-value.repository';
@Injectable() @Injectable()
export class NlpEntityRepository extends BaseRepository< export class NlpEntityRepository extends BaseRepository<
NlpEntity, NlpEntity,
@ -32,85 +27,7 @@ export class NlpEntityRepository extends BaseRepository<
NlpEntityFull, NlpEntityFull,
NlpEntityDto NlpEntityDto
> { > {
constructor( constructor(@InjectModel(NlpEntity.name) readonly model: Model<NlpEntity>) {
@InjectModel(NlpEntity.name) readonly model: Model<NlpEntity>,
private readonly nlpValueRepository: NlpValueRepository,
private readonly nlpSampleEntityRepository: NlpSampleEntityRepository,
) {
super(model, NlpEntity, NLP_ENTITY_POPULATE, NlpEntityFull); super(model, NlpEntity, NLP_ENTITY_POPULATE, NlpEntityFull);
} }
/**
* Post-create hook that triggers after an NLP entity is created.
* Emits an event to notify other parts of the system about the creation.
* Bypasses built-in entities.
*
* @param created - The newly created NLP entity document.
*/
async postCreate(_created: NlpEntityDocument): Promise<void> {
if (!_created.builtin) {
// Bypass builtin entities (probably fixtures)
this.eventEmitter.emit('hook:nlpEntity:create', _created);
}
}
/**
* Post-update hook that triggers after an NLP entity is updated.
* Emits an event to notify other parts of the system about the update.
* Bypasses built-in entities.
*
* @param query - The query used to find and update the entity.
* @param updated - The updated NLP entity document.
*/
async postUpdate(
_query: Query<
Document<NlpEntity, any, any>,
Document<NlpEntity, any, any>,
unknown,
NlpEntity,
'findOneAndUpdate'
>,
updated: NlpEntity,
): Promise<void> {
if (!updated?.builtin) {
// Bypass builtin entities (probably fixtures)
this.eventEmitter.emit('hook:nlpEntity:update', updated);
}
}
/**
* Pre-delete hook that triggers before an NLP entity is deleted.
* Deletes related NLP values and sample entities before the entity deletion.
* Emits an event to notify other parts of the system about the deletion.
* Bypasses built-in entities.
*
* @param query The query used to delete the entity.
* @param criteria The filter criteria used to find the entity for deletion.
*/
async preDelete(
_query: Query<
DeleteResult,
Document<NlpEntity, any, any>,
unknown,
NlpEntity,
'deleteOne' | 'deleteMany'
>,
criteria: TFilterQuery<NlpEntity>,
): Promise<void> {
if (criteria._id) {
await this.nlpValueRepository.deleteMany({ entity: criteria._id });
await this.nlpSampleEntityRepository.deleteMany({ entity: criteria._id });
const entities = await this.find(
typeof criteria === 'string' ? { _id: criteria } : criteria,
);
entities
.filter((e) => !e.builtin)
.map((e) => {
this.eventEmitter.emit('hook:nlpEntity:delete', e);
});
} else {
throw new Error('Attempted to delete NLP entity using unknown criteria');
}
}
} }

View File

@ -42,7 +42,7 @@ describe('NlpSampleEntityRepository', () => {
beforeAll(async () => { beforeAll(async () => {
const { getMocks } = await buildTestingMocks({ const { getMocks } = await buildTestingMocks({
models: ['NlpSampleModel'], models: ['NlpSampleModel', 'NlpValueModel'],
autoInjectFrom: ['providers'], autoInjectFrom: ['providers'],
imports: [rootMongooseTestModule(installNlpSampleEntityFixtures)], imports: [rootMongooseTestModule(installNlpSampleEntityFixtures)],
providers: [ providers: [

View File

@ -42,7 +42,12 @@ describe('NlpSampleRepository', () => {
const { getMocks } = await buildTestingMocks({ const { getMocks } = await buildTestingMocks({
autoInjectFrom: ['providers'], autoInjectFrom: ['providers'],
imports: [rootMongooseTestModule(installNlpSampleEntityFixtures)], imports: [rootMongooseTestModule(installNlpSampleEntityFixtures)],
providers: [NlpSampleRepository, NlpValueRepository, LanguageRepository], providers: [
NlpSampleRepository,
NlpValueRepository,
LanguageRepository,
NlpSampleEntityRepository,
],
}); });
[ [
nlpSampleRepository, nlpSampleRepository,

View File

@ -24,6 +24,7 @@ import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
import { TFilterQuery } from '@/utils/types/filter.types'; import { TFilterQuery } from '@/utils/types/filter.types';
import { TNlpSampleDto } from '../dto/nlp-sample.dto'; import { TNlpSampleDto } from '../dto/nlp-sample.dto';
import { NlpSampleEntity } from '../schemas/nlp-sample-entity.schema';
import { import {
NLP_SAMPLE_POPULATE, NLP_SAMPLE_POPULATE,
NlpSample, NlpSample,
@ -33,8 +34,6 @@ import {
} from '../schemas/nlp-sample.schema'; } from '../schemas/nlp-sample.schema';
import { NlpValue } from '../schemas/nlp-value.schema'; import { NlpValue } from '../schemas/nlp-value.schema';
import { NlpSampleEntityRepository } from './nlp-sample-entity.repository';
@Injectable() @Injectable()
export class NlpSampleRepository extends BaseRepository< export class NlpSampleRepository extends BaseRepository<
NlpSample, NlpSample,
@ -44,7 +43,8 @@ export class NlpSampleRepository extends BaseRepository<
> { > {
constructor( constructor(
@InjectModel(NlpSample.name) readonly model: Model<NlpSample>, @InjectModel(NlpSample.name) readonly model: Model<NlpSample>,
private readonly nlpSampleEntityRepository: NlpSampleEntityRepository, @InjectModel(NlpSampleEntity.name)
private readonly nlpSampleEntityModel: Model<NlpSampleEntity>,
) { ) {
super(model, NlpSample, NLP_SAMPLE_POPULATE, NlpSampleFull); super(model, NlpSample, NLP_SAMPLE_POPULATE, NlpSampleFull);
} }
@ -310,7 +310,7 @@ export class NlpSampleRepository extends BaseRepository<
criteria: TFilterQuery<NlpSample>, criteria: TFilterQuery<NlpSample>,
) { ) {
if (criteria._id) { if (criteria._id) {
await this.nlpSampleEntityRepository.deleteMany({ await this.nlpSampleEntityModel.deleteMany({
sample: criteria._id, sample: criteria._id,
}); });
} else { } else {

View File

@ -6,6 +6,10 @@
* 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). * 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 LlmNluHelper from '@/extensions/helpers/llm-nlu/index.helper';
import { HelperService } from '@/helper/helper.service';
import { SettingService } from '@/setting/services/setting.service';
import { IGNORED_TEST_FIELDS } from '@/utils/test/constants';
import { nlpEntityFixtures } from '@/utils/test/fixtures/nlpentity'; import { nlpEntityFixtures } from '@/utils/test/fixtures/nlpentity';
import { installNlpSampleEntityFixtures } from '@/utils/test/fixtures/nlpsampleentity'; import { installNlpSampleEntityFixtures } from '@/utils/test/fixtures/nlpsampleentity';
import { nlpValueFixtures } from '@/utils/test/fixtures/nlpvalue'; import { nlpValueFixtures } from '@/utils/test/fixtures/nlpvalue';
@ -18,7 +22,10 @@ import { TFixtures } from '@/utils/test/types';
import { buildTestingMocks } from '@/utils/test/utils'; import { buildTestingMocks } from '@/utils/test/utils';
import { NlpValue, NlpValueFull } from '../schemas/nlp-value.schema'; import { NlpValue, NlpValueFull } from '../schemas/nlp-value.schema';
import { NlpValueService } from '../services/nlp-value.service';
import { NlpService } from '../services/nlp.service';
import { NlpEntityRepository } from './nlp-entity.repository';
import { NlpSampleEntityRepository } from './nlp-sample-entity.repository'; import { NlpSampleEntityRepository } from './nlp-sample-entity.repository';
import { NlpValueRepository } from './nlp-value.repository'; import { NlpValueRepository } from './nlp-value.repository';
@ -26,20 +33,47 @@ describe('NlpValueRepository', () => {
let nlpValueRepository: NlpValueRepository; let nlpValueRepository: NlpValueRepository;
let nlpSampleEntityRepository: NlpSampleEntityRepository; let nlpSampleEntityRepository: NlpSampleEntityRepository;
let nlpValues: NlpValue[]; let nlpValues: NlpValue[];
let nlpService: NlpService;
let nlpEntityRepository: NlpEntityRepository;
let llmNluHelper: LlmNluHelper;
let nlpValueService: NlpValueService;
beforeAll(async () => { beforeAll(async () => {
const { getMocks } = await buildTestingMocks({ const { getMocks, module } = await buildTestingMocks({
models: ['NlpEntityModel'],
autoInjectFrom: ['providers'], autoInjectFrom: ['providers'],
imports: [rootMongooseTestModule(installNlpSampleEntityFixtures)], imports: [rootMongooseTestModule(installNlpSampleEntityFixtures)],
providers: [NlpValueRepository], providers: [
NlpService,
LlmNluHelper,
{
provide: SettingService,
useValue: {
getSettings: jest.fn(() => ({
chatbot_settings: {
default_nlu_helper: 'llm-nlu-helper',
},
})),
},
},
],
}); });
[nlpValueRepository, nlpSampleEntityRepository] = await getMocks([
[
nlpValueRepository,
nlpSampleEntityRepository,
nlpService,
nlpEntityRepository,
nlpValueService,
] = await getMocks([
NlpValueRepository, NlpValueRepository,
NlpSampleEntityRepository, NlpSampleEntityRepository,
NlpService,
NlpEntityRepository,
NlpValueService,
]); ]);
nlpValues = await nlpValueRepository.findAll(); nlpValues = await nlpValueRepository.findAll();
llmNluHelper = module.get(LlmNluHelper);
module.get(HelperService).register(llmNluHelper);
}); });
afterAll(closeInMongodConnection); afterAll(closeInMongodConnection);
@ -94,7 +128,14 @@ describe('NlpValueRepository', () => {
describe('The deleteCascadeOne function', () => { describe('The deleteCascadeOne function', () => {
it('should delete a nlp Value', async () => { it('should delete a nlp Value', async () => {
nlpValueRepository.eventEmitter.once(
'hook:nlpValue:preDelete',
async (...[query, criteria]) => {
await nlpService.handleValueDelete(query, criteria);
},
);
const result = await nlpValueRepository.deleteOne(nlpValues[1].id); const result = await nlpValueRepository.deleteOne(nlpValues[1].id);
expect(result.deletedCount).toEqual(1); expect(result.deletedCount).toEqual(1);
const sampleEntities = await nlpSampleEntityRepository.find({ const sampleEntities = await nlpSampleEntityRepository.find({
value: nlpValues[1].id, value: nlpValues[1].id,
@ -102,4 +143,93 @@ describe('NlpValueRepository', () => {
expect(sampleEntities.length).toEqual(0); expect(sampleEntities.length).toEqual(0);
}); });
}); });
describe('postCreate', () => {
it('should create and attach a foreign_id to the newly created nlp value', async () => {
nlpValueRepository.eventEmitter.once(
'hook:nlpValue:postCreate',
async (...[created]) => {
const helperSpy = jest.spyOn(llmNluHelper, 'addValue');
jest.spyOn(nlpValueService, 'updateOne');
await nlpService.handleValuePostCreate(created);
expect(helperSpy).toHaveBeenCalledWith(created);
expect(nlpValueService.updateOne).toHaveBeenCalledWith(
{
_id: created._id,
},
{ foreign_id: await helperSpy.mock.results[0].value },
);
},
);
const createdNlpEntity = await nlpEntityRepository.create({
name: 'test1',
});
const result = await nlpValueRepository.create({
entity: createdNlpEntity.id,
value: 'test',
});
const intentNlpEntity = await nlpValueRepository.findOne(result.id);
expect(intentNlpEntity?.foreign_id).toBeDefined();
expect(intentNlpEntity).toEqualPayload(result, [
...IGNORED_TEST_FIELDS,
'foreign_id',
]);
});
it('should not create and attach a foreign_id to the newly created nlp value with builtin set to true', async () => {
nlpValueRepository.eventEmitter.once(
'hook:nlpValue:postCreate',
async (...[created]) => {
await nlpService.handleValuePostCreate(created);
},
);
const createdNlpEntity = await nlpEntityRepository.create({
name: 'nlpEntityTest2',
});
const result = await nlpValueRepository.create({
entity: createdNlpEntity.id,
value: 'nlpValueTest2',
builtin: true,
});
const nlpValue = await nlpValueRepository.findOne(result.id);
expect(nlpValue?.foreign_id).toBeUndefined();
expect(nlpValue).toEqualPayload(result);
});
});
describe('postUpdate', () => {
it('should update an NlpValue and trigger a postUpdate event', async () => {
jest.spyOn(nlpService, 'handleValuePostUpdate');
jest.spyOn(llmNluHelper, 'updateValue');
nlpValueRepository.eventEmitter.once(
'hook:nlpValue:postUpdate',
async (...[query, updated]) => {
await nlpService.handleValuePostUpdate(query, updated);
expect(llmNluHelper.updateValue).toHaveBeenCalledWith(updated);
},
);
const updatedNlpValue = await nlpValueRepository.updateOne(
{
value: 'test',
},
{ value: 'test2' },
);
expect(nlpService.handleValuePostUpdate).toHaveBeenCalledTimes(1);
expect(llmNluHelper.updateValue).toHaveBeenCalledTimes(1);
const result = await nlpValueRepository.findOne(updatedNlpValue.id);
expect(result).toEqualPayload(updatedNlpValue);
});
});
}); });

View File

@ -9,16 +9,9 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose'; import { InjectModel } from '@nestjs/mongoose';
import { plainToInstance } from 'class-transformer'; import { plainToInstance } from 'class-transformer';
import { import { Model, PipelineStage, SortOrder, Types } from 'mongoose';
Document,
Model,
PipelineStage,
Query,
SortOrder,
Types,
} from 'mongoose';
import { BaseRepository, DeleteResult } from '@/utils/generics/base-repository'; import { BaseRepository } from '@/utils/generics/base-repository';
import { PageQueryDto } from '@/utils/pagination/pagination-query.dto'; import { PageQueryDto } from '@/utils/pagination/pagination-query.dto';
import { TFilterQuery } from '@/utils/types/filter.types'; import { TFilterQuery } from '@/utils/types/filter.types';
import { Format } from '@/utils/types/format.types'; import { Format } from '@/utils/types/format.types';
@ -27,7 +20,6 @@ import { NlpValueDto } from '../dto/nlp-value.dto';
import { import {
NLP_VALUE_POPULATE, NLP_VALUE_POPULATE,
NlpValue, NlpValue,
NlpValueDocument,
NlpValueFull, NlpValueFull,
NlpValueFullWithCount, NlpValueFullWithCount,
NlpValuePopulate, NlpValuePopulate,
@ -35,8 +27,6 @@ import {
TNlpValueCount, TNlpValueCount,
} from '../schemas/nlp-value.schema'; } from '../schemas/nlp-value.schema';
import { NlpSampleEntityRepository } from './nlp-sample-entity.repository';
@Injectable() @Injectable()
export class NlpValueRepository extends BaseRepository< export class NlpValueRepository extends BaseRepository<
NlpValue, NlpValue,
@ -44,82 +34,10 @@ export class NlpValueRepository extends BaseRepository<
NlpValueFull, NlpValueFull,
NlpValueDto NlpValueDto
> { > {
constructor( constructor(@InjectModel(NlpValue.name) readonly model: Model<NlpValue>) {
@InjectModel(NlpValue.name) readonly model: Model<NlpValue>,
private readonly nlpSampleEntityRepository: NlpSampleEntityRepository,
) {
super(model, NlpValue, NLP_VALUE_POPULATE, NlpValueFull); super(model, NlpValue, NLP_VALUE_POPULATE, NlpValueFull);
} }
/**
* Emits an event after a new NLP value is created, bypassing built-in values.
*
* @param created - The newly created NLP value document.
*/
async postCreate(created: NlpValueDocument): Promise<void> {
if (!created.builtin) {
// Bypass builtin entities (probably fixtures)
this.eventEmitter.emit('hook:nlpValue:create', created);
}
}
/**
* Emits an event after an NLP value is updated, bypassing built-in values.
*
* @param query - The query that was used to update the NLP value.
* @param updated - The updated NLP value document.
*/
async postUpdate(
_query: Query<
Document<NlpValue, any, any>,
Document<NlpValue, any, any>,
unknown,
NlpValue,
'findOneAndUpdate'
>,
updated: NlpValue,
): Promise<void> {
if (!updated?.builtin) {
// Bypass builtin entities (probably fixtures)
this.eventEmitter.emit('hook:nlpValue:update', updated);
}
}
/**
* Handles deletion of NLP values and associated entities. If the criteria includes an ID,
* emits an event for each deleted entity.
*
* @param _query - The query used to delete the NLP value(s).
* @param criteria - The filter criteria used to identify the NLP value(s) to delete.
*/
async preDelete(
_query: Query<
DeleteResult,
Document<NlpValue, any, any>,
unknown,
NlpValue,
'deleteOne' | 'deleteMany'
>,
criteria: TFilterQuery<NlpValue>,
): Promise<void> {
if (criteria._id) {
await this.nlpSampleEntityRepository.deleteMany({ value: criteria._id });
const entities = await this.find(
typeof criteria === 'string' ? { _id: criteria } : criteria,
);
entities
.filter((e) => !e.builtin)
.map((e) => {
this.eventEmitter.emit('hook:nlpValue:delete', e);
});
} else if (criteria.entity) {
// Do nothing : cascading deletes coming from Nlp Sample Entity
} else {
throw new Error('Attempted to delete a NLP value using unknown criteria');
}
}
private getSortDirection(sortOrder: SortOrder) { private getSortDirection(sortOrder: SortOrder) {
return typeof sortOrder === 'number' return typeof sortOrder === 'number'
? sortOrder ? sortOrder

View File

@ -8,15 +8,18 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
import { Document, Query } from 'mongoose';
import { HelperService } from '@/helper/helper.service'; import { HelperService } from '@/helper/helper.service';
import { HelperType, NLU } from '@/helper/types'; import { HelperType, NLU } from '@/helper/types';
import { LoggerService } from '@/logger/logger.service'; import { LoggerService } from '@/logger/logger.service';
import { TFilterQuery } from '@/utils/types/filter.types';
import { NlpEntity, NlpEntityDocument } from '../schemas/nlp-entity.schema'; import { NlpEntity, NlpEntityDocument } from '../schemas/nlp-entity.schema';
import { NlpValue, NlpValueDocument } from '../schemas/nlp-value.schema'; import { NlpValue, NlpValueDocument } from '../schemas/nlp-value.schema';
import { NlpEntityService } from './nlp-entity.service'; import { NlpEntityService } from './nlp-entity.service';
import { NlpSampleEntityService } from './nlp-sample-entity.service';
import { NlpSampleService } from './nlp-sample.service'; import { NlpSampleService } from './nlp-sample.service';
import { NlpValueService } from './nlp-value.service'; import { NlpValueService } from './nlp-value.service';
@ -28,6 +31,7 @@ export class NlpService {
protected readonly nlpEntityService: NlpEntityService, protected readonly nlpEntityService: NlpEntityService,
protected readonly nlpValueService: NlpValueService, protected readonly nlpValueService: NlpValueService,
protected readonly helperService: HelperService, protected readonly helperService: HelperService,
protected readonly nlpSampleEntityService: NlpSampleEntityService,
) {} ) {}
/** /**
@ -66,21 +70,25 @@ export class NlpService {
* *
* @param entity - The NLP entity to be created. * @param entity - The NLP entity to be created.
*/ */
@OnEvent('hook:nlpEntity:create') @OnEvent('hook:nlpEntity:postCreate')
async handleEntityCreate(entity: NlpEntityDocument) { async handleEntityPostCreate(created: NlpEntityDocument) {
// Synchonize new entity with NLP if (!created.builtin) {
try { // Synchonize new entity with NLP
const helper = await this.helperService.getDefaultHelper(HelperType.NLU); try {
const foreignId = await helper.addEntity(entity); const helper = await this.helperService.getDefaultHelper(
this.logger.debug('New entity successfully synced!', foreignId); HelperType.NLU,
await this.nlpEntityService.updateOne( );
{ _id: entity._id }, const foreignId = await helper.addEntity(created);
{ this.logger.debug('New entity successfully synced!', foreignId);
foreign_id: foreignId, await this.nlpEntityService.updateOne(
}, { _id: created._id },
); {
} catch (err) { foreign_id: foreignId,
this.logger.error('Unable to sync a new entity', err); },
);
} catch (err) {
this.logger.error('Unable to sync a new entity', err);
}
} }
} }
@ -89,37 +97,68 @@ export class NlpService {
* *
* @param entity - The NLP entity to be updated. * @param entity - The NLP entity to be updated.
*/ */
@OnEvent('hook:nlpEntity:update') @OnEvent('hook:nlpEntity:postUpdate')
async handleEntityUpdate(entity: NlpEntity) { async handleEntityPostUpdate(
// Synchonize new entity with NLP provider _query: Query<
try { Document<NlpEntity>,
const helper = await this.helperService.getDefaultNluHelper(); Document<NlpEntity>,
await helper.updateEntity(entity); unknown,
this.logger.debug('Updated entity successfully synced!', entity); NlpEntity,
} catch (err) { 'findOneAndUpdate'
this.logger.error('Unable to sync updated entity', err); >,
updated: NlpEntity,
) {
if (!updated?.builtin) {
// Synchonize new entity with NLP provider
try {
const helper = await this.helperService.getDefaultHelper(
HelperType.NLU,
);
await helper.updateEntity(updated);
this.logger.debug('Updated entity successfully synced!', updated);
} catch (err) {
this.logger.error('Unable to sync updated entity', err);
}
} }
} }
/** /**
* Handles the event triggered when an NLP entity is deleted. Synchronizes the deletion with the external NLP provider. * Before deleting a `nlpEntity`, this method deletes the related `nlpValue` and `nlpSampleEntity`. Synchronizes the deletion with the external NLP provider
* *
* @param entity - The NLP entity to be deleted. * @param _query - The Mongoose query object used for deletion.
* @param criteria - The filter criteria for finding the nlpEntities to be deleted.
*/ */
@OnEvent('hook:nlpEntity:delete') @OnEvent('hook:nlpEntity:preDelete')
async handleEntityDelete(entity: NlpEntity) { async handleEntityDelete(
// Synchonize new entity with NLP provider _query: unknown,
try { criteria: TFilterQuery<NlpEntity>,
if (entity.foreign_id) { ): Promise<void> {
const helper = await this.helperService.getDefaultNluHelper(); if (criteria._id) {
await helper.deleteEntity(entity.foreign_id); await this.nlpValueService.deleteMany({ entity: criteria._id });
this.logger.debug('Deleted entity successfully synced!', entity); await this.nlpSampleEntityService.deleteMany({ entity: criteria._id });
} else {
this.logger.error(`Entity ${entity} is missing foreign_id`); const entities = await this.nlpEntityService.find({
throw new NotFoundException(`Entity ${entity} is missing foreign_id`); ...(typeof criteria === 'string' ? { _id: criteria } : criteria),
builtin: false,
});
const helper = await this.helperService.getDefaultHelper(HelperType.NLU);
for (const entity of entities) {
// Synchonize new entity with NLP provider
try {
if (entity.foreign_id) {
await helper.deleteEntity(entity.foreign_id);
this.logger.debug('Deleted entity successfully synced!', entity);
} else {
this.logger.error(`Entity ${entity} is missing foreign_id`);
throw new NotFoundException(
`Entity ${entity} is missing foreign_id`,
);
}
} catch (err) {
this.logger.error('Unable to sync deleted entity', err);
}
} }
} catch (err) {
this.logger.error('Unable to sync deleted entity', err);
} }
} }
@ -128,21 +167,25 @@ export class NlpService {
* *
* @param value - The NLP value to be created. * @param value - The NLP value to be created.
*/ */
@OnEvent('hook:nlpValue:create') @OnEvent('hook:nlpValue:postCreate')
async handleValueCreate(value: NlpValueDocument) { async handleValuePostCreate(created: NlpValueDocument) {
// Synchonize new value with NLP provider if (!created.builtin) {
try { // Synchonize new value with NLP provider
const helper = await this.helperService.getDefaultNluHelper(); try {
const foreignId = await helper.addValue(value); const helper = await this.helperService.getDefaultHelper(
this.logger.debug('New value successfully synced!', foreignId); HelperType.NLU,
await this.nlpValueService.updateOne( );
{ _id: value._id }, const foreignId = await helper.addValue(created);
{ this.logger.debug('New value successfully synced!', foreignId);
foreign_id: foreignId, await this.nlpValueService.updateOne(
}, { _id: created._id },
); {
} catch (err) { foreign_id: foreignId,
this.logger.error('Unable to sync a new value', err); },
);
} catch (err) {
this.logger.error('Unable to sync a new value', err);
}
} }
} }
@ -151,37 +194,71 @@ export class NlpService {
* *
* @param value - The NLP value to be updated. * @param value - The NLP value to be updated.
*/ */
@OnEvent('hook:nlpValue:update') @OnEvent('hook:nlpValue:postUpdate')
async handleValueUpdate(value: NlpValue) { async handleValuePostUpdate(
// Synchonize new value with NLP provider _query: Query<
try { Document<NlpValue, any, any>,
const helper = await this.helperService.getDefaultNluHelper(); Document<NlpValue, any, any>,
await helper.updateValue(value); unknown,
this.logger.debug('Updated value successfully synced!', value); NlpValue,
} catch (err) { 'findOneAndUpdate'
this.logger.error('Unable to sync updated value', err); >,
updated: NlpValue,
) {
if (!updated?.builtin) {
// Synchonize new value with NLP provider
try {
const helper = await this.helperService.getDefaultHelper(
HelperType.NLU,
);
await helper.updateValue(updated);
this.logger.debug('Updated value successfully synced!', updated);
} catch (err) {
this.logger.error('Unable to sync updated value', err);
}
} }
} }
/** /**
* Handles the event triggered when an NLP value is deleted. Synchronizes the deletion with the external NLP provider. * Before deleting a `nlpValue`, this method deletes the related `nlpSampleEntity`. Synchronizes the deletion with the external NLP provider
* *
* @param value - The NLP value to be deleted. * @param _query - The Mongoose query object used for deletion.
* @param criteria - The filter criteria for finding the nlpValues to be deleted.
*/ */
@OnEvent('hook:nlpValue:delete') @OnEvent('hook:nlpValue:preDelete')
async handleValueDelete(value: NlpValue) { async handleValueDelete(
// Synchonize new value with NLP provider _query: unknown,
try { criteria: TFilterQuery<NlpValue>,
const helper = await this.helperService.getDefaultNluHelper(); ): Promise<void> {
const populatedValue = await this.nlpValueService.findOneAndPopulate( if (criteria._id) {
value.id, await this.nlpSampleEntityService.deleteMany({
); value: criteria._id,
if (populatedValue) { });
await helper.deleteValue(populatedValue);
this.logger.debug('Deleted value successfully synced!', value); const values = await this.nlpValueService.find({
...(typeof criteria === 'string' ? { _id: criteria } : criteria),
builtin: false,
});
const helper = await this.helperService.getDefaultHelper(HelperType.NLU);
for (const value of values) {
// Synchonize new value with NLP provider
try {
const populatedValue = await this.nlpValueService.findOneAndPopulate(
value.id,
);
if (populatedValue) {
await helper.deleteValue(populatedValue);
this.logger.debug('Deleted value successfully synced!', value);
}
} catch (err) {
this.logger.error('Unable to sync deleted value', err);
}
} }
} catch (err) { } else if (criteria.entity) {
this.logger.error('Unable to sync deleted value', err); // Do nothing : cascading deletes coming from Nlp Sample Entity
} else {
throw new Error('Attempted to delete a NLP value using unknown criteria');
} }
} }
} }

View File

@ -81,7 +81,7 @@ const position = {
y: 0, y: 0,
}; };
export const baseBlockInstance = { export const baseBlockInstance: Partial<BlockFull> = {
trigger_labels: [labelMock], trigger_labels: [labelMock],
assign_labels: [labelMock], assign_labels: [labelMock],
options: blockOptions, options: blockOptions,
@ -90,7 +90,7 @@ export const baseBlockInstance = {
position, position,
builtin: true, builtin: true,
attachedBlock: null, attachedBlock: null,
category: undefined, category: null,
previousBlocks: [], previousBlocks: [],
trigger_channels: [], trigger_channels: [],
nextBlocks: [], nextBlocks: [],

View File

@ -19,7 +19,7 @@ import type { Block, BlockFull } from '@/chat/schemas/block.schema';
import { type Category } from '@/chat/schemas/category.schema'; import { type Category } from '@/chat/schemas/category.schema';
import { type ContextVar } from '@/chat/schemas/context-var.schema'; import { type ContextVar } from '@/chat/schemas/context-var.schema';
import { type Conversation } from '@/chat/schemas/conversation.schema'; import { type Conversation } from '@/chat/schemas/conversation.schema';
import type { Label, LabelDocument } from '@/chat/schemas/label.schema'; import type { Label } from '@/chat/schemas/label.schema';
import { type Message } from '@/chat/schemas/message.schema'; import { type Message } from '@/chat/schemas/message.schema';
import { type Subscriber } from '@/chat/schemas/subscriber.schema'; import { type Subscriber } from '@/chat/schemas/subscriber.schema';
import { type ContentType } from '@/cms/schemas/content-type.schema'; import { type ContentType } from '@/cms/schemas/content-type.schema';
@ -27,16 +27,10 @@ import { type Content } from '@/cms/schemas/content.schema';
import { type Menu } from '@/cms/schemas/menu.schema'; import { type Menu } from '@/cms/schemas/menu.schema';
import { type Language } from '@/i18n/schemas/language.schema'; import { type Language } from '@/i18n/schemas/language.schema';
import { type Translation } from '@/i18n/schemas/translation.schema'; import { type Translation } from '@/i18n/schemas/translation.schema';
import type { import type { NlpEntity } from '@/nlp/schemas/nlp-entity.schema';
NlpEntity,
NlpEntityDocument,
} from '@/nlp/schemas/nlp-entity.schema';
import { type NlpSampleEntity } from '@/nlp/schemas/nlp-sample-entity.schema'; import { type NlpSampleEntity } from '@/nlp/schemas/nlp-sample-entity.schema';
import { type NlpSample } from '@/nlp/schemas/nlp-sample.schema'; import { type NlpSample } from '@/nlp/schemas/nlp-sample.schema';
import type { import type { NlpValue } from '@/nlp/schemas/nlp-value.schema';
NlpValue,
NlpValueDocument,
} from '@/nlp/schemas/nlp-value.schema';
import { type Setting } from '@/setting/schemas/setting.schema'; import { type Setting } from '@/setting/schemas/setting.schema';
import { type Invitation } from '@/user/schemas/invitation.schema'; import { type Invitation } from '@/user/schemas/invitation.schema';
import { type Model } from '@/user/schemas/model.schema'; import { type Model } from '@/user/schemas/model.schema';
@ -130,35 +124,18 @@ declare module '@nestjs/event-emitter' {
category: TDefinition<Category>; category: TDefinition<Category>;
contextVar: TDefinition<ContextVar>; contextVar: TDefinition<ContextVar>;
conversation: TDefinition<Conversation, { end: unknown; close: unknown }>; conversation: TDefinition<Conversation, { end: unknown; close: unknown }>;
label: TDefinition< label: TDefinition<Label>;
Label,
{ create: LabelDocument; delete: Label | Label[] }
>;
message: TDefinition<Message>; message: TDefinition<Message>;
subscriber: TDefinition<Subscriber, { assign: SubscriberUpdateDto }>; subscriber: TDefinition<Subscriber, { assign: SubscriberUpdateDto }>;
contentType: TDefinition<ContentType>; contentType: TDefinition<ContentType>;
content: TDefinition<Content>; content: TDefinition<Content>;
menu: TDefinition<Menu>; menu: TDefinition<Menu>;
language: TDefinition<Language, { delete: Language | Language[] }>; language: TDefinition<Language>;
translation: TDefinition<Translation>; translation: TDefinition<Translation>;
nlpEntity: TDefinition< nlpEntity: TDefinition<NlpEntity>;
NlpEntity,
{
create: NlpEntityDocument;
update: NlpEntity;
delete: NlpEntity | NlpEntity[];
}
>;
nlpSampleEntity: TDefinition<NlpSampleEntity>; nlpSampleEntity: TDefinition<NlpSampleEntity>;
nlpSample: TDefinition<NlpSample>; nlpSample: TDefinition<NlpSample>;
nlpValue: TDefinition< nlpValue: TDefinition<NlpValue>;
NlpValue,
{
create: NlpValueDocument;
update: NlpValue;
delete: NlpValue | NlpValue[];
}
>;
setting: TDefinition<Setting>; setting: TDefinition<Setting>;
invitation: TDefinition<Invitation>; invitation: TDefinition<Invitation>;
model: TDefinition<Model>; model: TDefinition<Model>;
@ -212,7 +189,8 @@ declare module '@nestjs/event-emitter' {
type TPostUpdateValidate<T> = FilterQuery<T>; type TPostUpdateValidate<T> = FilterQuery<T>;
type TPostUpdate<T> = THydratedDocument<T>; // TODO this type will be optimized soon in a separated PR
type TPostUpdate<T> = T & any;
type TPostDelete = DeleteResult; type TPostDelete = DeleteResult;