diff --git a/src/app.module.ts b/src/app.module.ts index b90861d..3b21c80 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -25,6 +25,7 @@ import { AppThrottlerGuard } from './common/guards/app-throttler.guard'; import { ThrottlerModule } from '@nestjs/throttler'; import { LoggingModule } from './common/modules/logging/logging.module'; import { ChatbotModule } from './modules/chatbot/chatbot.module'; +import { ConversationModule } from './modules/conversation/conversation.module'; import openRouterConfig from './config/open-router.config'; const ENV = process.env.NODE_ENV; @@ -106,6 +107,7 @@ const ENV = process.env.NODE_ENV; AdminModule, LoggingModule, ChatbotModule, + ConversationModule, ], providers: [ { diff --git a/src/i18n/en/chatbot.json b/src/i18n/en/chatbot.json new file mode 100644 index 0000000..5be5021 --- /dev/null +++ b/src/i18n/en/chatbot.json @@ -0,0 +1,6 @@ +{ + "fields": { + "conversationId": "Conversation Id", + "message": "Message" + } +} \ No newline at end of file diff --git a/src/i18n/fa/chatbot.json b/src/i18n/fa/chatbot.json new file mode 100644 index 0000000..195c8c3 --- /dev/null +++ b/src/i18n/fa/chatbot.json @@ -0,0 +1,6 @@ +{ + "fields": { + "conversationId": "شناسه مکالمه", + "message": "پیام" + } +} \ No newline at end of file diff --git a/src/modules/chatbot/chatbot.controller.ts b/src/modules/chatbot/chatbot.controller.ts index f62657b..3371a9a 100644 --- a/src/modules/chatbot/chatbot.controller.ts +++ b/src/modules/chatbot/chatbot.controller.ts @@ -1,6 +1,7 @@ -import { Body, Controller, Post } from '@nestjs/common'; +import { Body, Controller, Post, Res } from '@nestjs/common'; import { ChatbotService } from './providers/chatbot.service'; import { Public } from '../auth/decorators/public.decorator'; +import type { Response } from 'express'; @Controller('chatbot') export class ChatbotController { @@ -17,4 +18,52 @@ export class ChatbotController { public async chat(@Body('message') message: string) { return this.chatbotService.chat(message); } + + @Post('stream') + // TEMP: + @Public() + public async streamChat( + @Body('message') message: string, + @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)) { + res.write( + `data: ${JSON.stringify({ + message: content, + })}\n\n`, + ); + } + + 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/constants/index.ts b/src/modules/chatbot/constants/index.ts new file mode 100644 index 0000000..4a46919 --- /dev/null +++ b/src/modules/chatbot/constants/index.ts @@ -0,0 +1,14 @@ +export const CHATBOT_SYSTEM_PROMPT = ` + You are a medical AI assistant named Heala (Or in persian: هیلا). + + Your role is to provide general medical information + and health education. + + - Do not provide definitive diagnoses. + - Do not prescribe medications or dosages. + - Do not fabricate medical information. + - If symptoms may indicate an emergency, recommend + seeking urgent medical care. + - Clearly communicate uncertainty when appropriate. + - Answer in Persian when the user asks in Persian. +`; diff --git a/src/modules/chatbot/dtos/chat.dto.ts b/src/modules/chatbot/dtos/chat.dto.ts new file mode 100644 index 0000000..e742d1f --- /dev/null +++ b/src/modules/chatbot/dtos/chat.dto.ts @@ -0,0 +1,24 @@ +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { i18nValidationMessage as t } from 'nestjs-i18n'; + +export class ChatDTO { + @IsString({ + message: t('validation.wrongFieldFormat', { + field: '$t(chatbot.fields.conversationId)', + }), + }) + @IsOptional() + conversationId?: string; + + @IsString({ + message: t('validation.wrongFieldFormat', { + field: '$t(chatbot.fields.message)', + }), + }) + @IsNotEmpty({ + message: t('validation.requiredField', { + field: '$t(chatbot.fields.message)', + }), + }) + message!: string; +} diff --git a/src/modules/chatbot/http/chatbot.post.endpoints.http b/src/modules/chatbot/http/chatbot.post.endpoints.http index 800a668..d73aca2 100644 --- a/src/modules/chatbot/http/chatbot.post.endpoints.http +++ b/src/modules/chatbot/http/chatbot.post.endpoints.http @@ -4,4 +4,7 @@ Content-Type: application/json { "message": "چطوری بفهمم به HPV مبتلا هستم یا خیر؟" -} \ No newline at end of file +} + +# POST {{baseUrl}}/chatbot/stream/ +# Content-Type: application/json \ No newline at end of file diff --git a/src/modules/chatbot/providers/chatbot.service.ts b/src/modules/chatbot/providers/chatbot.service.ts index 1034379..ef1b00b 100644 --- a/src/modules/chatbot/providers/chatbot.service.ts +++ b/src/modules/chatbot/providers/chatbot.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { CHATBOT_SYSTEM_PROMPT } from '../constants'; @Injectable() export class ChatbotService { @@ -14,7 +15,7 @@ export class ChatbotService { const { OpenRouter } = await import('@openrouter/sdk'); return new OpenRouter({ - apiKey: this.configService.get('openRouter.apiKey'), + apiKey: this.configService.getOrThrow('openRouter.apiKey'), }); } @@ -23,24 +24,11 @@ export class ChatbotService { const response = await openRouter.chat.send({ chatRequest: { - model: this.configService.get('openRouter.model'), + model: this.configService.getOrThrow('openRouter.model'), messages: [ { role: 'system', - content: ` - You are a medical AI assistant named Heala (Or in persian: هیلا). - - Your role is to provide general medical information - and health education. - - - Do not provide definitive diagnoses. - - Do not prescribe medications or dosages. - - Do not fabricate medical information. - - If symptoms may indicate an emergency, recommend - seeking urgent medical care. - - Clearly communicate uncertainty when appropriate. - - Answer in Persian when the user asks in Persian. - `.trim(), + content: CHATBOT_SYSTEM_PROMPT.trim(), }, { role: 'user', @@ -64,4 +52,37 @@ export class ChatbotService { message: content, }; } + + public async *streamChat(message: string) { + 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, + }, + ], + stream: true, + }, + }); + + if ('choices' in response) { + throw new Error('Expected streaming response'); + } + + for await (const chunk of response) { + const content = chunk.choices[0]?.delta?.content; + + if (content) { + yield content; + } + } + } } diff --git a/src/modules/conversation/conversation.module.ts b/src/modules/conversation/conversation.module.ts new file mode 100644 index 0000000..10a915f --- /dev/null +++ b/src/modules/conversation/conversation.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Conversation } from './entities/conversation.entity'; +import { Message } from './entities/message.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Conversation, Message])], +}) +export class ConversationModule {} diff --git a/src/modules/conversation/entities/conversation.entity.ts b/src/modules/conversation/entities/conversation.entity.ts new file mode 100644 index 0000000..7dc84b0 --- /dev/null +++ b/src/modules/conversation/entities/conversation.entity.ts @@ -0,0 +1,32 @@ +import { User } from '@/modules/users/entities/user.entity'; +import { + Column, + CreateDateColumn, + Entity, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Message } from './message.entity'; + +@Entity() +export class Conversation { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ type: 'varchar', nullable: true }) + title!: string | null; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + user!: User; + + @OneToMany(() => Message, (message) => message.conversation) + messages!: Message[]; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} diff --git a/src/modules/conversation/entities/message.entity.ts b/src/modules/conversation/entities/message.entity.ts new file mode 100644 index 0000000..d791d2c --- /dev/null +++ b/src/modules/conversation/entities/message.entity.ts @@ -0,0 +1,33 @@ +import { + Column, + CreateDateColumn, + Entity, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Conversation } from './conversation.entity'; +import { MessageRole } from '../enums/message-role.enum'; + +@Entity() +export class Message { + @PrimaryGeneratedColumn() + id!: number; + + @ManyToOne(() => Conversation, (conversation) => conversation.messages, { + onDelete: 'CASCADE', + }) + conversation!: Conversation; + + @Column({ + type: 'enum', + enum: MessageRole, + nullable: false, + }) + role!: MessageRole; + + @Column({ type: 'text' }) + content!: string; + + @CreateDateColumn() + createdAt!: Date; +} diff --git a/src/modules/conversation/enums/conversation-type.enum.ts b/src/modules/conversation/enums/conversation-type.enum.ts new file mode 100644 index 0000000..6c20ae8 --- /dev/null +++ b/src/modules/conversation/enums/conversation-type.enum.ts @@ -0,0 +1,4 @@ +export enum ConversationType { + AI = 'ai', + DOCTOR = 'doctor', +} diff --git a/src/modules/conversation/enums/message-role.enum.ts b/src/modules/conversation/enums/message-role.enum.ts new file mode 100644 index 0000000..b18e035 --- /dev/null +++ b/src/modules/conversation/enums/message-role.enum.ts @@ -0,0 +1,5 @@ +export enum MessageRole { + USER = 'user', + ASSISTANT = 'assistant', + DOCTOR = 'doctor', +} diff --git a/src/modules/conversation/providers/conversation.service.ts b/src/modules/conversation/providers/conversation.service.ts new file mode 100644 index 0000000..ae70762 --- /dev/null +++ b/src/modules/conversation/providers/conversation.service.ts @@ -0,0 +1,44 @@ +import { 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'; + +@Injectable() +export class ConversationService { + constructor( + /** + * Inject Conversation Repository + */ + @InjectRepository(Conversation) + private readonly conversationRepository: Repository, + + /** + * Inject Message Repository + */ + @InjectRepository(Message) + private readonly messageRepository: Repository, + ) {} + + public async createConversation( + user: User, + title?: string, + ): Promise { + const conversation = this.conversationRepository.create({ + user, + title: title ?? null, + }); + + try { + return await this.conversationRepository.save(conversation); + } catch (err) { + throw new AppTimeoutException(err); + } + } + + // public async findConversationById(id: number, user: User): Promise { + // const + // } +}