From 5c4e9df3411e5147cddf5b13f698be4c93e32edb Mon Sep 17 00:00:00 2001 From: radmehr Date: Mon, 5 Oct 2026 13:59:03 +0330 Subject: [PATCH] feat: connect chatbot to conversation database --- src/i18n/en/conversation.json | 5 + src/i18n/fa/conversation.json | 5 + src/migrations/1791194400675-Conversation.ts | 35 ++++++ .../auth/http/verify-otp.post.endpoints.http | 2 +- src/modules/chatbot/chatbot.controller.ts | 62 ++++----- src/modules/chatbot/chatbot.module.ts | 2 + src/modules/chatbot/dtos/chat.dto.ts | 6 +- .../chatbot/http/chatbot.post.endpoints.http | 2 +- .../chatbot/providers/chatbot.service.ts | 118 ++++++++++++++---- .../conversation/conversation.module.ts | 3 + .../providers/conversation.service.ts | 75 ++++++++++- 11 files changed, 246 insertions(+), 69 deletions(-) create mode 100644 src/i18n/en/conversation.json create mode 100644 src/i18n/fa/conversation.json create mode 100644 src/migrations/1791194400675-Conversation.ts diff --git a/src/i18n/en/conversation.json b/src/i18n/en/conversation.json new file mode 100644 index 0000000..cc1953e --- /dev/null +++ b/src/i18n/en/conversation.json @@ -0,0 +1,5 @@ +{ + "fields": { + "conversation": "Conversation" + } +} \ No newline at end of file diff --git a/src/i18n/fa/conversation.json b/src/i18n/fa/conversation.json new file mode 100644 index 0000000..4e52278 --- /dev/null +++ b/src/i18n/fa/conversation.json @@ -0,0 +1,5 @@ +{ + "fields": { + "conversation": "مکالمه" + } +} \ No newline at end of file diff --git a/src/migrations/1791194400675-Conversation.ts b/src/migrations/1791194400675-Conversation.ts new file mode 100644 index 0000000..5145db6 --- /dev/null +++ b/src/migrations/1791194400675-Conversation.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class Conversation1791194400675 implements MigrationInterface { + name = 'Conversation1791194400675'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "conversation" ("id" SERIAL NOT NULL, "title" character varying, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "userId" integer, CONSTRAINT "PK_864528ec4274360a40f66c29845" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TYPE "public"."message_role_enum" AS ENUM('user', 'assistant', 'doctor')`, + ); + await queryRunner.query( + `CREATE TABLE "message" ("id" SERIAL NOT NULL, "role" "public"."message_role_enum" NOT NULL, "content" text NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "conversationId" integer, CONSTRAINT "PK_ba01f0a3e0123651915008bc578" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `ALTER TABLE "conversation" ADD CONSTRAINT "FK_c308b1cd542522bb66430fa860a" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "message" ADD CONSTRAINT "FK_7cf4a4df1f2627f72bf6231635f" FOREIGN KEY ("conversationId") REFERENCES "conversation"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "message" DROP CONSTRAINT "FK_7cf4a4df1f2627f72bf6231635f"`, + ); + await queryRunner.query( + `ALTER TABLE "conversation" DROP CONSTRAINT "FK_c308b1cd542522bb66430fa860a"`, + ); + await queryRunner.query(`DROP TABLE "message"`); + await queryRunner.query(`DROP TYPE "public"."message_role_enum"`); + await queryRunner.query(`DROP TABLE "conversation"`); + } +} diff --git a/src/modules/auth/http/verify-otp.post.endpoints.http b/src/modules/auth/http/verify-otp.post.endpoints.http index 25b61d3..fa1b5e6 100644 --- a/src/modules/auth/http/verify-otp.post.endpoints.http +++ b/src/modules/auth/http/verify-otp.post.endpoints.http @@ -3,7 +3,7 @@ Content-Type: application/json { "phone": "09333026363", - "otp": "55333" + "otp": "23122" } {{ diff --git a/src/modules/chatbot/chatbot.controller.ts b/src/modules/chatbot/chatbot.controller.ts index 3371a9a..43c135f 100644 --- a/src/modules/chatbot/chatbot.controller.ts +++ b/src/modules/chatbot/chatbot.controller.ts @@ -1,7 +1,16 @@ -import { Body, Controller, Post, Res } from '@nestjs/common'; +import { + Body, + Controller, + HttpCode, + HttpStatus, + Post, + Res, +} from '@nestjs/common'; import { ChatbotService } from './providers/chatbot.service'; -import { Public } from '../auth/decorators/public.decorator'; import type { Response } from 'express'; +import { ChatDTO } from './dtos/chat.dto'; +import { ActiveUser } from '@/common/decorators/active-user.decorator'; +import { User } from '../users/entities/user.entity'; @Controller('chatbot') export class ChatbotController { @@ -13,24 +22,31 @@ export class ChatbotController { ) {} @Post('chat') - // TEMP: - @Public() - public async chat(@Body('message') message: string) { - return this.chatbotService.chat(message); + @HttpCode(HttpStatus.OK) + public async chat(@Body() chatDto: ChatDTO, @ActiveUser() user: User) { + return this.chatbotService.chat( + user, + chatDto.message, + chatDto.conversationId, + ); } @Post('stream') - // TEMP: - @Public() + @HttpCode(HttpStatus.OK) public async streamChat( - @Body('message') message: string, + @Body() chatDto: ChatDTO, + @ActiveUser() user: User, @Res() res: Response, ) { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); - for await (const content of this.chatbotService.streamChat(message)) { + for await (const content of this.chatbotService.streamChat( + user, + chatDto.message, + chatDto.conversationId, + )) { res.write( `data: ${JSON.stringify({ message: content, @@ -40,30 +56,4 @@ export class ChatbotController { res.end(); } - - // @Sse('stream') - // @Public() - // public streamChat( - // @Body('message') message: string, - // ): Observable { - // return new Observable((subscriber) => { - // const run = async () => { - // try { - // for await (const content of this.chatbotService.streamChat(message)) { - // subscriber.next({ - // data: { - // message: content, - // }, - // }); - // } - - // subscriber.complete(); - // } catch (err) { - // subscriber.error(err); - // } - // }; - - // void run(); - // }); - // } } diff --git a/src/modules/chatbot/chatbot.module.ts b/src/modules/chatbot/chatbot.module.ts index 73948e3..3900dc9 100644 --- a/src/modules/chatbot/chatbot.module.ts +++ b/src/modules/chatbot/chatbot.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { ChatbotController } from './chatbot.controller'; import { ChatbotService } from './providers/chatbot.service'; +import { ConversationModule } from '../conversation/conversation.module'; @Module({ + imports: [ConversationModule], controllers: [ChatbotController], providers: [ChatbotService], }) diff --git a/src/modules/chatbot/dtos/chat.dto.ts b/src/modules/chatbot/dtos/chat.dto.ts index e742d1f..36f52ef 100644 --- a/src/modules/chatbot/dtos/chat.dto.ts +++ b/src/modules/chatbot/dtos/chat.dto.ts @@ -1,14 +1,14 @@ -import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { i18nValidationMessage as t } from 'nestjs-i18n'; export class ChatDTO { - @IsString({ + @IsInt({ message: t('validation.wrongFieldFormat', { field: '$t(chatbot.fields.conversationId)', }), }) @IsOptional() - conversationId?: string; + conversationId?: number; @IsString({ message: t('validation.wrongFieldFormat', { diff --git a/src/modules/chatbot/http/chatbot.post.endpoints.http b/src/modules/chatbot/http/chatbot.post.endpoints.http index d73aca2..d78be35 100644 --- a/src/modules/chatbot/http/chatbot.post.endpoints.http +++ b/src/modules/chatbot/http/chatbot.post.endpoints.http @@ -1,6 +1,6 @@ POST {{baseUrl}}/chatbot/chat Content-Type: application/json -# Authorization: Bearer {{$global.accessToken}} +Authorization: Bearer {{$global.accessToken}} { "message": "چطوری بفهمم به HPV مبتلا هستم یا خیر؟" diff --git a/src/modules/chatbot/providers/chatbot.service.ts b/src/modules/chatbot/providers/chatbot.service.ts index ef1b00b..40e363f 100644 --- a/src/modules/chatbot/providers/chatbot.service.ts +++ b/src/modules/chatbot/providers/chatbot.service.ts @@ -1,6 +1,10 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { CHATBOT_SYSTEM_PROMPT } from '../constants'; +import { User } from '@/modules/users/entities/user.entity'; +import { Conversation } from '@/modules/conversation/entities/conversation.entity'; +import { ConversationService } from '@/modules/conversation/providers/conversation.service'; +import { MessageRole } from '@/modules/conversation/enums/message-role.enum'; @Injectable() export class ChatbotService { @@ -9,6 +13,11 @@ export class ChatbotService { * Inject Config Service */ private readonly configService: ConfigService, + + /** + * Inject Conversation Service + */ + private readonly conversationService: ConversationService, ) {} private async getOpenRouter() { @@ -19,22 +28,61 @@ export class ChatbotService { }); } - public async chat(message: string) { + private async getConversationMessages( + user: User, + message: string, + conversationId?: number, + ) { + let conversation: Conversation; + + if (conversationId) { + conversation = await this.conversationService.findConversationById( + conversationId, + user, + ); + } else { + conversation = await this.conversationService.createConversation(user); + } + + await this.conversationService.addMessage( + conversation, + MessageRole.USER, + message, + ); + + const messages = await this.conversationService.getMessages(conversation); + + return { + conversation, + messages: [ + { + role: 'system' as const, + content: CHATBOT_SYSTEM_PROMPT.trim(), + }, + ...messages.map((message) => ({ + role: + message.role === MessageRole.USER + ? ('user' as const) + : ('assistant' as const), + content: message.content, + })), + ], + }; + } + + public async chat(user: User, message: string, conversationId?: number) { + const { conversation, messages } = await this.getConversationMessages( + user, + message, + conversationId, + ); + const openRouter = await this.getOpenRouter(); const response = await openRouter.chat.send({ chatRequest: { model: this.configService.getOrThrow('openRouter.model'), - messages: [ - { - role: 'system', - content: CHATBOT_SYSTEM_PROMPT.trim(), - }, - { - role: 'user', - content: message, - }, - ], + messages, }, }); @@ -48,27 +96,35 @@ export class ChatbotService { throw new Error('OpenRouter returned an unexpected response'); } + await this.conversationService.addMessage( + conversation, + MessageRole.ASSISTANT, + content, + ); + return { + conversationId: conversation.id, message: content, }; } - public async *streamChat(message: string) { + public async *streamChat( + user: User, + message: string, + conversationId?: number, + ) { + const { conversation, messages } = await this.getConversationMessages( + user, + message, + conversationId, + ); + const openRouter = await this.getOpenRouter(); const response = await openRouter.chat.send({ chatRequest: { model: this.configService.getOrThrow('openRouter.model'), - messages: [ - { - role: 'system', - content: CHATBOT_SYSTEM_PROMPT.trim(), - }, - { - role: 'user', - content: message, - }, - ], + messages, stream: true, }, }); @@ -77,12 +133,26 @@ export class ChatbotService { throw new Error('Expected streaming response'); } + let assistantMessage = ''; + for await (const chunk of response) { const content = chunk.choices[0]?.delta?.content; - if (content) { - yield content; + if (!content) { + continue; } + + assistantMessage += content; + + yield { conversationId: conversation.id, content }; + } + + if (assistantMessage) { + await this.conversationService.addMessage( + conversation, + MessageRole.ASSISTANT, + assistantMessage, + ); } } } diff --git a/src/modules/conversation/conversation.module.ts b/src/modules/conversation/conversation.module.ts index 10a915f..7c9f634 100644 --- a/src/modules/conversation/conversation.module.ts +++ b/src/modules/conversation/conversation.module.ts @@ -2,8 +2,11 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Conversation } from './entities/conversation.entity'; import { Message } from './entities/message.entity'; +import { ConversationService } from './providers/conversation.service'; @Module({ imports: [TypeOrmModule.forFeature([Conversation, Message])], + providers: [ConversationService], + exports: [ConversationService], }) export class ConversationModule {} diff --git a/src/modules/conversation/providers/conversation.service.ts b/src/modules/conversation/providers/conversation.service.ts index ae70762..2dfeec6 100644 --- a/src/modules/conversation/providers/conversation.service.ts +++ b/src/modules/conversation/providers/conversation.service.ts @@ -1,10 +1,12 @@ -import { Injectable } from '@nestjs/common'; +import { HttpStatus, Injectable } from '@nestjs/common'; import { Repository } from 'typeorm'; import { Conversation } from '../entities/conversation.entity'; import { InjectRepository } from '@nestjs/typeorm'; import { Message } from '../entities/message.entity'; import { User } from '@/modules/users/entities/user.entity'; import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception'; +import { AppException } from '@/common/exceptions/app.exception'; +import { MessageRole } from '../enums/message-role.enum'; @Injectable() export class ConversationService { @@ -38,7 +40,72 @@ export class ConversationService { } } - // public async findConversationById(id: number, user: User): Promise { - // const - // } + public async findConversationById( + id: number, + user: User, + ): Promise { + let conversation: Conversation | null = null; + + try { + conversation = await this.conversationRepository.findOne({ + where: { + id, + user: { + id: user.id, + }, + }, + }); + } catch (err) { + throw new AppTimeoutException(err); + } + + if (!conversation) { + throw new AppException( + 'common.errors.entityNotFound', + HttpStatus.NOT_FOUND, + { entity: '$t(conversations.fields.conversation)' }, + ); + } + + return conversation; + } + + public async addMessage( + conversation: Conversation, + role: MessageRole, + content: string, + ): Promise { + const message = this.messageRepository.create({ + conversation, + role, + content, + }); + + try { + return await this.messageRepository.save(message); + } catch (err) { + throw new AppTimeoutException(err); + } + } + + public async getMessages(conversation: Conversation): Promise { + let messages: Array = []; + + try { + messages = await this.messageRepository.find({ + where: { + conversation: { + id: conversation.id, + }, + }, + order: { + createdAt: 'ASC', + }, + }); + } catch (err) { + throw new AppTimeoutException(err); + } + + return messages; + } }