From 76f6c97a9e5cfc629bd961cc1dea14d217f78541 Mon Sep 17 00:00:00 2001 From: radmehr Date: Sun, 27 Sep 2026 17:45:11 +0330 Subject: [PATCH] feat: add chatbot module and service --- package-lock.json | 18 +++++ package.json | 1 + src/app.module.ts | 11 ++- src/config/environment.validation.ts | 2 + src/config/open-router.config.ts | 6 ++ src/modules/chatbot/chatbot.controller.ts | 20 +++++ src/modules/chatbot/chatbot.module.ts | 9 +++ .../chatbot/http/chatbot.post.endpoints.http | 7 ++ .../chatbot/providers/chatbot.service.ts | 77 +++++++++++++++++++ 9 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 src/config/open-router.config.ts create mode 100644 src/modules/chatbot/chatbot.controller.ts create mode 100644 src/modules/chatbot/chatbot.module.ts create mode 100644 src/modules/chatbot/http/chatbot.post.endpoints.http create mode 100644 src/modules/chatbot/providers/chatbot.service.ts diff --git a/package-lock.json b/package-lock.json index be9534f..af0b0f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@nestjs/swagger": "^11.2.6", "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^11.0.0", + "@openrouter/sdk": "^1.3.33", "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.3", @@ -3086,6 +3087,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@openrouter/sdk": { + "version": "1.3.33", + "resolved": "https://registry.npmjs.org/@openrouter/sdk/-/sdk-1.3.33.tgz", + "integrity": "sha512-MrrmDSxbHxX5XrhWlT+cfN1yMf8xu078LiQKtelazpQdHwrHSbyPBd+2y7rx77P3Uix+7uj8MTOX5lPrpx7nwQ==", + "hasInstallScript": true, + "dependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -12063,6 +12073,14 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index d223ff0..e4aa1f8 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@nestjs/swagger": "^11.2.6", "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^11.0.0", + "@openrouter/sdk": "^1.3.33", "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.3", diff --git a/src/app.module.ts b/src/app.module.ts index 57c316e..b90861d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -24,6 +24,8 @@ import { AdminModule } from './modules/admin/admin.module'; 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 openRouterConfig from './config/open-router.config'; const ENV = process.env.NODE_ENV; @@ -48,7 +50,13 @@ const ENV = process.env.NODE_ENV; ConfigModule.forRoot({ isGlobal: true, envFilePath: !ENV ? '.env' : `.env.${ENV}`, - load: [appConfig, databaseConfig, jwtConfig, redisConfig], + load: [ + appConfig, + databaseConfig, + jwtConfig, + redisConfig, + openRouterConfig, + ], validationSchema: environmentValidation, }), @@ -97,6 +105,7 @@ const ENV = process.env.NODE_ENV; ScoringModule, AdminModule, LoggingModule, + ChatbotModule, ], providers: [ { diff --git a/src/config/environment.validation.ts b/src/config/environment.validation.ts index 096bf6a..ccf986a 100644 --- a/src/config/environment.validation.ts +++ b/src/config/environment.validation.ts @@ -23,4 +23,6 @@ export default Joi.object({ CORS_SUBDOMAIN: Joi.string().required(), SWAGGER_SERVERS: Joi.string().required(), LOG_LEVEL: Joi.string().default('info'), + OPENROUTER_API_KEY: Joi.string().required(), + OPENROUTER_MODEL: Joi.string().required(), }); diff --git a/src/config/open-router.config.ts b/src/config/open-router.config.ts new file mode 100644 index 0000000..2b384aa --- /dev/null +++ b/src/config/open-router.config.ts @@ -0,0 +1,6 @@ +import { registerAs } from '@nestjs/config'; + +export default registerAs('openRouter', () => ({ + apiKey: process.env.OPENROUTER_API_KEY, + model: process.env.OPENROUTER_MODEL, +})); diff --git a/src/modules/chatbot/chatbot.controller.ts b/src/modules/chatbot/chatbot.controller.ts new file mode 100644 index 0000000..f62657b --- /dev/null +++ b/src/modules/chatbot/chatbot.controller.ts @@ -0,0 +1,20 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { ChatbotService } from './providers/chatbot.service'; +import { Public } from '../auth/decorators/public.decorator'; + +@Controller('chatbot') +export class ChatbotController { + constructor( + /** + * Inject Chatbot Service + */ + private readonly chatbotService: ChatbotService, + ) {} + + @Post('chat') + // TEMP: + @Public() + public async chat(@Body('message') message: string) { + return this.chatbotService.chat(message); + } +} diff --git a/src/modules/chatbot/chatbot.module.ts b/src/modules/chatbot/chatbot.module.ts new file mode 100644 index 0000000..73948e3 --- /dev/null +++ b/src/modules/chatbot/chatbot.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { ChatbotController } from './chatbot.controller'; +import { ChatbotService } from './providers/chatbot.service'; + +@Module({ + controllers: [ChatbotController], + providers: [ChatbotService], +}) +export class ChatbotModule {} diff --git a/src/modules/chatbot/http/chatbot.post.endpoints.http b/src/modules/chatbot/http/chatbot.post.endpoints.http new file mode 100644 index 0000000..800a668 --- /dev/null +++ b/src/modules/chatbot/http/chatbot.post.endpoints.http @@ -0,0 +1,7 @@ +POST {{baseUrl}}/chatbot/chat +Content-Type: application/json +# Authorization: Bearer {{$global.accessToken}} + +{ + "message": "چطوری بفهمم به HPV مبتلا هستم یا خیر؟" +} \ No newline at end of file diff --git a/src/modules/chatbot/providers/chatbot.service.ts b/src/modules/chatbot/providers/chatbot.service.ts new file mode 100644 index 0000000..c2ba239 --- /dev/null +++ b/src/modules/chatbot/providers/chatbot.service.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class ChatbotService { + private openRouter: any; + + constructor( + /** + * Inject Config Service + */ + private readonly configService: ConfigService, + ) {} + + private async getOpenRouter() { + if (!this.openRouter) { + const { OpenRouter } = await import('@openrouter/sdk'); + + this.openRouter = new OpenRouter({ + apiKey: this.configService.get('openRouter.apiKey'), + }); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return this.openRouter; + } + + public async chat(message: string) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const openRouter = await this.getOpenRouter(); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + const response = await openRouter.chat.send({ + chatRequest: { + model: this.configService.get('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(), + }, + { + role: 'user', + content: message, + }, + ], + }, + }); + + if (response instanceof ReadableStream) { + throw new Error('Expected non-streaming response'); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const content = response.choices[0]?.message?.content; + + if (typeof content !== 'string') { + throw new Error('OpenRouter returned an unexpected response'); + } + + return { + message: content, + }; + } +}