From db266a1cd6d59e5893dc29df68e8e5444da2a4be Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Sat, 29 Nov 2025 12:25:13 +0330 Subject: [PATCH] chatbot : step 1 --- src/IOC/ioc.config.ts | 14 + src/IOC/ioc.types.ts | 8 + src/app.ts | 1 + .../chatbot/DTO/create-chat-session.dto.ts | 10 +- src/modules/chatbot/DTO/send-message.dto.ts | 25 +- .../chatbot/DTO/session-id.param.dto.ts | 15 +- src/modules/chatbot/chatbot.controller.ts | 209 ++++----- .../models/Abstraction/IChatMessage.ts | 29 ++ .../models/Abstraction/IChatSession.ts | 19 + .../chatbot/models/chat-message.model.ts | 29 ++ .../chatbot/models/chat-session.model.ts | 26 ++ .../chatbot/providers/chatbot.service.ts | 439 +++++++++--------- .../chatbot/providers/data-context.service.ts | 225 ++------- .../chatbot/providers/langchain.service.ts | 196 ++------ src/modules/chatbot/providers/llm.service.ts | 33 +- .../providers/websocket-auth.service.ts | 60 ++- .../repositories/chat-message.repository.ts | 57 +-- .../repositories/chat-session.repository.ts | 59 +-- 18 files changed, 689 insertions(+), 765 deletions(-) create mode 100644 src/modules/chatbot/models/Abstraction/IChatMessage.ts create mode 100644 src/modules/chatbot/models/Abstraction/IChatSession.ts create mode 100644 src/modules/chatbot/models/chat-message.model.ts create mode 100644 src/modules/chatbot/models/chat-session.model.ts diff --git a/src/IOC/ioc.config.ts b/src/IOC/ioc.config.ts index 0fb9b01..3ca7c18 100644 --- a/src/IOC/ioc.config.ts +++ b/src/IOC/ioc.config.ts @@ -54,6 +54,13 @@ import { ChatService } from "../modules/chat/chat.service"; import { ChatRepo, createChatRepo } from "../modules/chat/repository/chat.repository"; import { ChatMessageRepo, createChatMessageRepo } from "../modules/chat/repository/message.repository"; import { WsAuthService } from "../modules/chat/wsAuth.service"; +import { ChatbotService } from "../modules/chatbot/providers/chatbot.service"; +import { DataContextService } from "../modules/chatbot/providers/data-context.service"; +import { LangChainService } from "../modules/chatbot/providers/langchain.service"; +import { LLMService } from "../modules/chatbot/providers/llm.service"; +import { WebSocketAuthService } from "../modules/chatbot/providers/websocket-auth.service"; +import { ChatSessionRepository, createChatSessionRepository } from "../modules/chatbot/repositories/chat-session.repository"; +import { ChatMessageRepository, createChatMessageRepository } from "../modules/chatbot/repositories/chat-message.repository"; import { ContactUsRepo, CreateContactUsRepo } from "../modules/contact-us/contactUs.repository"; import { ContactUsService } from "../modules/contact-us/contactUs.service"; import { CouponRepo, CouponUsageRepo, createCouponRepo, createCouponUsageRepo } from "../modules/Coupon/coupon.repository"; @@ -197,6 +204,11 @@ const containerModules = new AsyncContainerModule(async (bind) => { bind(IOCTYPES.LearningService).to(LearningService).inSingletonScope(); bind(IOCTYPES.RedisService).to(RedisService).inSingletonScope(); bind(IOCTYPES.CacheService).to(CacheService).inSingletonScope(); + bind(IOCTYPES.ChatbotService).to(ChatbotService).inSingletonScope(); + bind(IOCTYPES.ChatbotLLMService).to(LLMService).inSingletonScope(); + bind(IOCTYPES.ChatbotLangChainService).to(LangChainService).inSingletonScope(); + bind(IOCTYPES.ChatbotDataContextService).to(DataContextService).inSingletonScope(); + bind(IOCTYPES.ChatbotWebSocketAuthService).to(WebSocketAuthService).inSingletonScope(); // #endregion // #region repository bind(IOCTYPES.CategoryRepository).toDynamicValue(createCategoryRepo).inSingletonScope(); @@ -279,6 +291,8 @@ const containerModules = new AsyncContainerModule(async (bind) => { bind(IOCTYPES.AboutUsRepo).toDynamicValue(CreateAboutUsRepo).inSingletonScope(); bind(IOCTYPES.SiteSettingRepo).toDynamicValue(CreateSiteSettingRepo).inSingletonScope(); bind(IOCTYPES.NewsletterRepo).toDynamicValue(CreateNewsletterRepo).inSingletonScope(); + bind(IOCTYPES.ChatSessionRepository).toDynamicValue(createChatSessionRepository).inSingletonScope(); + bind(IOCTYPES.ChatMessageRepository).toDynamicValue(createChatMessageRepository).inSingletonScope(); // #endregion }); diff --git a/src/IOC/ioc.types.ts b/src/IOC/ioc.types.ts index a328336..0845871 100644 --- a/src/IOC/ioc.types.ts +++ b/src/IOC/ioc.types.ts @@ -46,6 +46,12 @@ export const IOCTYPES = { NewsletterService: Symbol.for("NewsletterService"), PricingService: Symbol.for("PricingService"), OrderQueue: Symbol.for("OrderQueue"), + ChatbotService: Symbol.for("ChatbotService"), + ChatbotLLMService: Symbol.for("ChatbotLLMService"), + ChatbotLangChainService: Symbol.for("ChatbotLangChainService"), + ChatbotDataContextService: Symbol.for("ChatbotDataContextService"), + ChatbotGateway: Symbol.for("ChatbotGateway"), + ChatbotWebSocketAuthService: Symbol.for("ChatbotWebSocketAuthService"), // #endregion // #region repository CategoryRepository: Symbol.for("CategoryRepository"), @@ -131,6 +137,8 @@ export const IOCTYPES = { ContactUsRepo: Symbol.for("ContactUsRepo"), AboutUsRepo: Symbol.for("AboutUsRepo"), NewsletterRepo: Symbol.for("NewsletterRepo"), + ChatSessionRepository: Symbol.for("ChatSessionRepository"), + ChatMessageRepository: Symbol.for("ChatMessageRepository"), // #endregion Logger: Symbol.for("Logger"), ZarinPalGateway: Symbol.for("ZarinPalGateway"), diff --git a/src/app.ts b/src/app.ts index f05d625..5ef19ec 100644 --- a/src/app.ts +++ b/src/app.ts @@ -31,6 +31,7 @@ import "./modules/blog/blog.controller"; import "./modules/landing/landing.controller"; import "./modules/ticket/ticket.controller"; import "./modules/chat/chat.controller"; +import "./modules/chatbot/chatbot.controller"; import "./modules/job/job.controller"; import "./modules/faq/faq.controller"; import "./modules/notification/notification.controller"; diff --git a/src/modules/chatbot/DTO/create-chat-session.dto.ts b/src/modules/chatbot/DTO/create-chat-session.dto.ts index 2753f13..6acfe70 100644 --- a/src/modules/chatbot/DTO/create-chat-session.dto.ts +++ b/src/modules/chatbot/DTO/create-chat-session.dto.ts @@ -1,13 +1,17 @@ -import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { Expose } from "class-transformer"; import { IsOptional, IsString, MaxLength } from "class-validator"; +import { ApiProperty } from "../../../common/decorator/swggerDocs"; + export class CreateChatSessionDto { - @ApiProperty({ description: "Title for the chat session", example: "Help with payment issues" }) + @Expose() @IsString() @MaxLength(255) + @ApiProperty({ type: "string", description: "Title for the chat session", example: "Help with payment issues" }) title!: string; + @Expose() @IsOptional() - @ApiPropertyOptional({ description: "Initial context for the chat", example: { user: "John Doe", orderId: "123456" } }) + @ApiProperty({ type: "object", description: "Initial context for the chat", example: { user: "John Doe", orderId: "123456" }, required: false }) context?: Record; } diff --git a/src/modules/chatbot/DTO/send-message.dto.ts b/src/modules/chatbot/DTO/send-message.dto.ts index 68e9317..1560845 100644 --- a/src/modules/chatbot/DTO/send-message.dto.ts +++ b/src/modules/chatbot/DTO/send-message.dto.ts @@ -1,22 +1,31 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { IsOptional, IsString, IsUUID, MaxLength } from "class-validator"; +import { Expose } from "class-transformer"; +import { IsOptional, IsString, MaxLength } from "class-validator"; +import { isValidObjectId } from "mongoose"; + +import { ApiProperty } from "../../../common/decorator/swggerDocs"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { ChatSessionModel } from "../models/chat-session.model"; export class SendMessageDto { - @ApiProperty({ description: "Message content", example: "How can I cancel my subscription?" }) + @Expose() @IsString() @MaxLength(2000) + @ApiProperty({ type: "string", description: "Message content", example: "How can I cancel my subscription?" }) content!: string; - @ApiProperty({ description: "Chat session ID" }) - @IsUUID() + @Expose() + @IsValidId([ChatSessionModel]) + @ApiProperty({ type: "string", description: "Chat session ID" }) sessionId!: string; - @ApiProperty({ description: "ID of message being replied to", required: false }) + @Expose() @IsOptional() - @IsUUID() + @IsString() + @ApiProperty({ type: "string", description: "ID of message being replied to", required: false }) responseToId?: string; - @ApiProperty({ description: "Additional metadata for the message", required: false }) + @Expose() @IsOptional() + @ApiProperty({ type: "object", description: "Additional metadata for the message", required: false }) metadata?: Record; } diff --git a/src/modules/chatbot/DTO/session-id.param.dto.ts b/src/modules/chatbot/DTO/session-id.param.dto.ts index 801e21c..6babf49 100644 --- a/src/modules/chatbot/DTO/session-id.param.dto.ts +++ b/src/modules/chatbot/DTO/session-id.param.dto.ts @@ -1,11 +1,16 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { IsNotEmpty, IsUUID } from "class-validator"; +import { Expose } from "class-transformer"; +import { IsNotEmpty } from "class-validator"; +import { isValidObjectId } from "mongoose"; +import { ApiProperty } from "../../../common/decorator/swggerDocs"; import { CommonMessage } from "../../../common/enums/message.enum"; +import { IsValidId } from "../../../common/decorator/validation.decorator"; +import { ChatSessionModel } from "../models/chat-session.model"; export class SessionIdParamDto { - @IsNotEmpty({ message: CommonMessage.SESSION_ID_REQUIRED }) - @IsUUID("7", { message: CommonMessage.SESSION_ID_SHOULD_BE_UUID }) - @ApiProperty({ description: "Session id of the entity", example: "8b1e8b1e-8b1e-8b1e-8b1e-8b1e8b1e8b1e" }) + @Expose() + @IsNotEmpty({ message: CommonMessage.SESSION_ID_REQUIRED || "Session ID is required" }) + @IsValidId([ChatSessionModel]) + @ApiProperty({ type: "string", description: "Session id of the entity", example: "66eff8c0c6ad5530b996c432" }) sessionId!: string; } diff --git a/src/modules/chatbot/chatbot.controller.ts b/src/modules/chatbot/chatbot.controller.ts index d6624e5..5fa329c 100644 --- a/src/modules/chatbot/chatbot.controller.ts +++ b/src/modules/chatbot/chatbot.controller.ts @@ -1,148 +1,149 @@ -import { Body, Controller, Get, Param, Post, Put, Query, Res } from "@nestjs/common"; -import { ApiOperation, ApiResponse } from "@nestjs/swagger"; -import { FastifyReply } from "fastify"; +import { Request, Response } from "express"; +import { inject } from "inversify"; +import { controller, httpGet, httpPost, httpPut, queryParam, request, requestBody, requestParam, response } from "inversify-express-utils"; import { CreateChatSessionDto } from "./DTO/create-chat-session.dto"; import { SendMessageDto } from "./DTO/send-message.dto"; import { SessionIdParamDto } from "./DTO/session-id.param.dto"; import { ChatbotService } from "./providers/chatbot.service"; -import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; -import { UserDec } from "../../common/decorators/user.decorator"; -import { PaginationDto } from "../../common/DTO/pagination.dto"; +import { BaseController } from "../../common/base/controller"; +import { ApiAuth, ApiModel, ApiOperation, ApiParam, ApiResponse, ApiTags } from "../../common/decorator/swggerDocs"; +import { PaginationDTO } from "../../common/dto/pagination.dto"; +import { HttpStatus } from "../../common/enums/httpStatus.enum"; +import { Guard } from "../../core/middlewares/guard.middleware"; +import { ValidationMiddleware } from "../../core/middlewares/validator.middleware"; +import { IOCTYPES } from "../../IOC/ioc.types"; +import { IUser } from "../user/models/Abstraction/IUser"; -@Controller("chatbot") -@AuthGuards() -export class ChatbotController { - constructor(private chatbotService: ChatbotService) {} +@controller("/chatbot") +@ApiTags("Chatbot") +class ChatbotController extends BaseController { + @inject(IOCTYPES.ChatbotService) private chatbotService: ChatbotService; - @Post("sessions") - @ApiOperation({ summary: "Create a new chat session" }) - @ApiResponse({ status: 201, description: "Chat session created successfully" }) - createSession(@UserDec("id") userId: string, @Body() createDto: CreateChatSessionDto) { - return this.chatbotService.createChatSession(userId, createDto); + @ApiOperation("Create a new chat session") + @ApiResponse("Chat session created successfully", HttpStatus.Created) + @ApiModel(CreateChatSessionDto) + @ApiAuth() + @httpPost("/sessions", Guard.authUser(), ValidationMiddleware.validateInput(CreateChatSessionDto)) + public async createSession(@request() req: Request, @requestBody() createDto: CreateChatSessionDto) { + const user = req.user as IUser; + const data = await this.chatbotService.createChatSession(user._id.toString(), createDto); + return this.response({ data }, HttpStatus.Created); } - @Get("sessions") - @ApiOperation({ summary: "Get user's chat sessions" }) - @ApiResponse({ status: 200, description: "Chat sessions retrieved successfully" }) - getUserSessions(@UserDec("id") userId: string, @Query() queryDto: PaginationDto) { - return this.chatbotService.getUserChatSessions(userId, queryDto.limit); + @ApiOperation("Get user's chat sessions") + @ApiResponse("Chat sessions retrieved successfully") + @ApiAuth() + @httpGet("/sessions", Guard.authUser(), ValidationMiddleware.validateQuery(PaginationDTO)) + public async getUserSessions(@request() req: Request, @queryParam() queryDto: PaginationDTO) { + const user = req.user as IUser; + const limit = queryDto.limit || 10; + const data = await this.chatbotService.getUserChatSessions(user._id.toString(), limit); + return this.response({ data }); } - @Get("sessions/:sessionId") - @ApiOperation({ summary: "Get a specific chat session with messages" }) - @ApiResponse({ status: 200, description: "Chat session retrieved successfully" }) - getSession(@UserDec("id") userId: string, @Param() param: SessionIdParamDto) { - return this.chatbotService.getChatSession(param.sessionId, userId); + @ApiOperation("Get a specific chat session with messages") + @ApiResponse("Chat session retrieved successfully") + @ApiParam("sessionId", "Session ID", true) + @ApiAuth() + @httpGet("/sessions/:sessionId", Guard.authUser(), ValidationMiddleware.validateParameter(SessionIdParamDto)) + public async getSession(@request() req: Request, @requestParam() param: SessionIdParamDto) { + const user = req.user as IUser; + const data = await this.chatbotService.getChatSession(param.sessionId, user._id.toString()); + return this.response({ data }); } - @Post("messages") - @ApiOperation({ summary: "Send a message in a chat session (Enhanced with LangChain + Fallback)" }) - @ApiResponse({ status: 201, description: "Message sent successfully with enhanced AI" }) - sendMessage(@UserDec("id") userId: string, @Body() sendDto: SendMessageDto) { - return this.chatbotService.sendMessage(userId, sendDto); + @ApiOperation("Send a message in a chat session (Enhanced with LangChain + Fallback)") + @ApiResponse("Message sent successfully with enhanced AI", HttpStatus.Created) + @ApiModel(SendMessageDto) + @ApiAuth() + @httpPost("/messages", Guard.authUser(), ValidationMiddleware.validateInput(SendMessageDto)) + public async sendMessage(@request() req: Request, @requestBody() sendDto: SendMessageDto) { + const user = req.user as IUser; + const data = await this.chatbotService.sendMessage(user._id.toString(), sendDto); + return this.response({ data }, HttpStatus.Created); } - @Post("messages/stream") - @ApiOperation({ summary: "Send a message and get streaming response (Enhanced with LangChain + Fallback)" }) - @ApiResponse({ status: 200, description: "Enhanced streaming response initiated" }) - async sendMessageStream(@UserDec("id") userId: string, @Body() sendDto: SendMessageDto, @Res() res: FastifyReply): Promise { + @ApiOperation("Send a message and get streaming response (Enhanced with LangChain + Fallback)") + @ApiResponse("Enhanced streaming response initiated") + @ApiModel(SendMessageDto) + @ApiAuth() + @httpPost("/messages/stream", Guard.authUser(), ValidationMiddleware.validateInput(SendMessageDto)) + public async sendMessageStream( + @request() req: Request, + @requestBody() sendDto: SendMessageDto, + @response() res: Response, + ): Promise { + const user = req.user as IUser; try { // Now uses LangChain by default with automatic Gemini fallback - const { userMessage, streamGenerator } = await this.chatbotService.sendMessageStream(userId, sendDto); + const { userMessage, streamGenerator } = await this.chatbotService.sendMessageStream(user._id.toString(), sendDto); // Set headers for Server-Sent Events - res.header("Content-Type", "text/event-stream"); - res.header("Cache-Control", "no-cache"); - res.header("Connection", "keep-alive"); - res.header("Access-Control-Allow-Origin", "*"); + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + res.setHeader("Access-Control-Allow-Origin", "*"); // Send initial user message - res.raw.write(`data: ${JSON.stringify({ type: "user_message", data: userMessage, enhanced: true })}\n\n`); + res.write(`data: ${JSON.stringify({ type: "user_message", data: userMessage, enhanced: true })}\n\n`); // Start streaming bot response - res.raw.write(`data: ${JSON.stringify({ type: "bot_response_start", provider: "enhanced" })}\n\n`); + res.write(`data: ${JSON.stringify({ type: "bot_response_start", provider: "enhanced" })}\n\n`); const stream = await streamGenerator(); for await (const chunk of stream) { if (chunk) { - res.raw.write(`data: ${JSON.stringify({ type: "bot_response_chunk", data: chunk })}\n\n`); + res.write(`data: ${JSON.stringify({ type: "bot_response_chunk", data: chunk })}\n\n`); } } // End streaming - res.raw.write(`data: ${JSON.stringify({ type: "bot_response_end" })}\n\n`); - res.raw.end(); + res.write(`data: ${JSON.stringify({ type: "bot_response_end" })}\n\n`); + res.end(); } catch (error) { console.error(error); - res.raw.write( + res.write( `data: ${JSON.stringify({ type: "error", data: "خطا در تولید پاسخ هوشمند. LangChain با خطا مواجه شد و به Gemini بازگشت ⚠️" })}\n\n`, ); - res.raw.end(); + res.end(); } } - @Post("training/refresh") - @ApiOperation({ summary: "Refresh LangChain training data from database" }) - @ApiResponse({ status: 200, description: "Training data refreshed successfully" }) - refreshTrainingData(@UserDec("id") _userId: string) { - return this.chatbotService.refreshLangChainData(); + @ApiOperation("Refresh LangChain training data from database") + @ApiResponse("Training data refreshed successfully") + @ApiAuth() + @httpPost("/training/refresh", Guard.authUser()) + public async refreshTrainingData(@request() _req: Request) { + const data = await this.chatbotService.refreshLangChainData(); + return this.response({ data }); } - @Put("sessions/:sessionId/close") - @ApiOperation({ summary: "Close a chat session" }) - @ApiResponse({ status: 200, description: "Chat session closed successfully" }) - closeSession(@UserDec("id") userId: string, @Param() param: SessionIdParamDto) { - return this.chatbotService.closeChatSession(param.sessionId, userId); + @ApiOperation("Close a chat session") + @ApiResponse("Chat session closed successfully") + @ApiParam("sessionId", "Session ID", true) + @ApiAuth() + @httpPut("/sessions/:sessionId/close", Guard.authUser(), ValidationMiddleware.validateParameter(SessionIdParamDto)) + public async closeSession(@request() req: Request, @requestParam() param: SessionIdParamDto) { + const user = req.user as IUser; + const data = await this.chatbotService.closeChatSession(param.sessionId, user._id.toString()); + return this.response({ data }); } - @Put("sessions/:sessionId/messages/read") - @ApiOperation({ summary: "Mark messages as read" }) - @ApiResponse({ status: 200, description: "Messages marked as read successfully" }) - markMessagesAsRead(@UserDec("id") userId: string, @Param() param: SessionIdParamDto, @Body() body: { messageIds: string[] }) { - return this.chatbotService.markMessagesAsRead(param.sessionId, userId, body.messageIds); + @ApiOperation("Mark messages as read") + @ApiResponse("Messages marked as read successfully") + @ApiParam("sessionId", "Session ID", true) + @ApiAuth() + @httpPut("/sessions/:sessionId/messages/read", Guard.authUser(), ValidationMiddleware.validateParameter(SessionIdParamDto)) + public async markMessagesAsRead( + @request() req: Request, + @requestParam() param: SessionIdParamDto, + @requestBody() body: { messageIds: string[] }, + ) { + const user = req.user as IUser; + const data = await this.chatbotService.markMessagesAsRead(param.sessionId, user._id.toString(), body.messageIds); + return this.response({ data }); } - - // @Post("messages/langchain") - // @ApiOperation({ summary: "Send a message using LangChain with enhanced RAG capabilities" }) - // @ApiResponse({ status: 201, description: "Message sent successfully with LangChain", type: ChatMessageResponseDto }) - // sendMessageWithLangChain(@UserDec("id") userId: string, @Body() sendDto: SendMessageDto): Promise { - // return this.chatbotService.sendMessageWithLangChain(userId, sendDto); - // } - - // @Post("messages/langchain/stream") - // @ApiOperation({ summary: "Send a message and get streaming response using LangChain" }) - // @ApiResponse({ status: 200, description: "Streaming response initiated with LangChain" }) - // async sendMessageStreamWithLangChain(@UserDec("id") userId: string, @Body() sendDto: SendMessageDto, @Res() res: FastifyReply): Promise { - // try { - // const { userMessage, streamGenerator } = await this.chatbotService.sendMessageStreamWithLangChain(userId, sendDto); - - // // Set headers for Server-Sent Events - // res.header("Content-Type", "text/event-stream"); - // res.header("Cache-Control", "no-cache"); - // res.header("Connection", "keep-alive"); - // res.header("Access-Control-Allow-Origin", "*"); - - // // Send initial user message - // res.raw.write(`data: ${JSON.stringify({ type: "user_message", data: userMessage })}\n\n`); - - // // Start streaming bot response - // res.raw.write(`data: ${JSON.stringify({ type: "bot_response_start", provider: "langchain" })}\n\n`); - - // const stream = await streamGenerator(); - // for await (const chunk of stream) { - // if (chunk) { - // res.raw.write(`data: ${JSON.stringify({ type: "bot_response_chunk", data: chunk })}\n\n`); - // } - // } - - // // End streaming - // res.raw.write(`data: ${JSON.stringify({ type: "bot_response_end" })}\n\n`); - // res.raw.end(); - // } catch (error) { - // console.error(error); - // res.raw.write(`data: ${JSON.stringify({ type: "error", data: "خطا در تولید پاسخ با LangChain. لطفاً دوباره تلاش کنید. ⚠️" })}\n\n`); - // res.raw.end(); - // } - // } } + +export { ChatbotController }; diff --git a/src/modules/chatbot/models/Abstraction/IChatMessage.ts b/src/modules/chatbot/models/Abstraction/IChatMessage.ts new file mode 100644 index 0000000..15956ec --- /dev/null +++ b/src/modules/chatbot/models/Abstraction/IChatMessage.ts @@ -0,0 +1,29 @@ +import { Types } from "mongoose"; + +export enum MessageType { + USER = "user", + BOT = "bot", + SYSTEM = "system", +} + +export enum MessageStatus { + SENT = "sent", + DELIVERED = "delivered", + READ = "read", + FAILED = "failed", +} + +export interface IChatMessage { + _id: Types.ObjectId; + content: string; + type: MessageType; + status: MessageStatus; + session: Types.ObjectId; + sender?: Types.ObjectId; + metadata?: Record; + responseToId?: string; + tokensUsed: number; + createdAt: Date; + updatedAt: Date; +} + diff --git a/src/modules/chatbot/models/Abstraction/IChatSession.ts b/src/modules/chatbot/models/Abstraction/IChatSession.ts new file mode 100644 index 0000000..6f94fa3 --- /dev/null +++ b/src/modules/chatbot/models/Abstraction/IChatSession.ts @@ -0,0 +1,19 @@ +import { Types } from "mongoose"; + +export enum ChatSessionStatus { + ACTIVE = "active", + CLOSED = "closed", + ARCHIVED = "archived", +} + +export interface IChatSession { + _id: Types.ObjectId; + title: string; + user: Types.ObjectId; + status: ChatSessionStatus; + context?: Record; + lastMessageAt?: Date; + createdAt: Date; + updatedAt: Date; +} + diff --git a/src/modules/chatbot/models/chat-message.model.ts b/src/modules/chatbot/models/chat-message.model.ts new file mode 100644 index 0000000..f996c38 --- /dev/null +++ b/src/modules/chatbot/models/chat-message.model.ts @@ -0,0 +1,29 @@ +import { Schema, model } from "mongoose"; + +import { IChatMessage, MessageStatus, MessageType } from "./Abstraction/IChatMessage"; + +const chatMessageSchema = new Schema( + { + content: { type: String, required: true }, + type: { type: String, enum: MessageType, required: true }, + status: { type: String, enum: MessageStatus, default: MessageStatus.SENT }, + session: { type: Schema.Types.ObjectId, ref: "ChatSession", required: true }, + sender: { type: Schema.Types.ObjectId, ref: "User", default: null }, + metadata: { type: Schema.Types.Mixed, default: null }, + responseToId: { type: String, default: null }, + tokensUsed: { type: Number, default: 0 }, + }, + { + timestamps: true, + toJSON: { virtuals: true, versionKey: false }, + id: false, + }, +); + +chatMessageSchema.index({ session: 1, createdAt: -1 }); +chatMessageSchema.index({ session: 1, createdAt: 1 }); + +const ChatMessageModel = model("ChatMessage", chatMessageSchema); + +export { ChatMessageModel, MessageStatus, MessageType }; + diff --git a/src/modules/chatbot/models/chat-session.model.ts b/src/modules/chatbot/models/chat-session.model.ts new file mode 100644 index 0000000..5a90227 --- /dev/null +++ b/src/modules/chatbot/models/chat-session.model.ts @@ -0,0 +1,26 @@ +import { Schema, model } from "mongoose"; + +import { ChatSessionStatus, IChatSession } from "./Abstraction/IChatSession"; + +const chatSessionSchema = new Schema( + { + title: { type: String, required: true, maxlength: 255 }, + user: { type: Schema.Types.ObjectId, ref: "User", required: true }, + status: { type: String, enum: ChatSessionStatus, default: ChatSessionStatus.ACTIVE }, + context: { type: Schema.Types.Mixed, default: null }, + lastMessageAt: { type: Date, default: null }, + }, + { + timestamps: true, + toJSON: { virtuals: true, versionKey: false }, + id: false, + }, +); + +chatSessionSchema.index({ user: 1, lastMessageAt: -1 }); +chatSessionSchema.index({ status: 1 }); + +const ChatSessionModel = model("ChatSession", chatSessionSchema); + +export { ChatSessionModel, ChatSessionStatus }; + diff --git a/src/modules/chatbot/providers/chatbot.service.ts b/src/modules/chatbot/providers/chatbot.service.ts index b9321a2..fdddc4d 100644 --- a/src/modules/chatbot/providers/chatbot.service.ts +++ b/src/modules/chatbot/providers/chatbot.service.ts @@ -1,80 +1,86 @@ -import { EntityManager } from "@mikro-orm/postgresql"; -import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; +import { inject, injectable } from "inversify"; +import { isValidObjectId } from "mongoose"; import { LangChainService } from "./langchain.service"; import { LLMService } from "./llm.service"; -import { User } from "../../users/entities/user.entity"; -// import { UsersService } from "../../users/services/users.service"; +import { UserModel } from "../../user/models/user.model"; import { CreateChatSessionDto } from "../DTO/create-chat-session.dto"; import { SendMessageDto } from "../DTO/send-message.dto"; -import { ChatMessage, MessageStatus, MessageType } from "../entities/chat-message.entity"; -import { ChatSession, ChatSessionStatus } from "../entities/chat-session.entity"; +import { ChatMessageModel, MessageStatus, MessageType } from "../models/chat-message.model"; +import { ChatSessionModel, ChatSessionStatus } from "../models/chat-session.model"; import { IChatContext } from "../interfaces/chatbot.interface"; +import { IChatMessage } from "../models/Abstraction/IChatMessage"; +import { IChatSession } from "../models/Abstraction/IChatSession"; import { ChatMessageRepository } from "../repositories/chat-message.repository"; import { ChatSessionRepository } from "../repositories/chat-session.repository"; +import { BadRequestError, NotFoundError } from "../../../core/app/app.errors"; +import { Logger } from "../../../core/logging/logger"; +import { IOCTYPES } from "../../../IOC/ioc.types"; export enum LLMProvider { LANGCHAIN = "langchain", GEMINI = "gemini", } -@Injectable() +@injectable() export class ChatbotService { - private readonly logger = new Logger(ChatbotService.name); + private readonly logger: Logger; private defaultProvider: LLMProvider; constructor( - private em: EntityManager, - private llmService: LLMService, - private langChainService: LangChainService, - private chatSessionRepo: ChatSessionRepository, - private chatMessageRepo: ChatMessageRepository, - private configService: ConfigService, - // private usersService: UsersService, + @inject(IOCTYPES.ChatbotLLMService) private llmService: LLMService, + @inject(IOCTYPES.ChatbotLangChainService) private langChainService: LangChainService, + @inject(IOCTYPES.ChatSessionRepository) private chatSessionRepo: ChatSessionRepository, + @inject(IOCTYPES.ChatMessageRepository) private chatMessageRepo: ChatMessageRepository, ) { + this.logger = new Logger("ChatbotService"); // Configure default provider - LangChain for enhanced responses - this.defaultProvider = this.configService.get("DEFAULT_LLM_PROVIDER") === "gemini" ? LLMProvider.GEMINI : LLMProvider.LANGCHAIN; - - this.logger.log(`Using ${this.defaultProvider} as default LLM provider`); + this.defaultProvider = process.env.DEFAULT_LLM_PROVIDER === "gemini" ? LLMProvider.GEMINI : LLMProvider.LANGCHAIN; + this.logger.info(`Using ${this.defaultProvider} as default LLM provider`); } async createChatSession(userId: string, createDto: CreateChatSessionDto) { - const em = this.em.fork(); - const user = await em.findOne(User, { id: userId }); - if (!user) { - throw new NotFoundException("User not found"); + if (!isValidObjectId(userId)) { + throw new BadRequestError("Invalid user ID"); } - const session = new ChatSession(); - session.title = createDto.title; - session.user = user; - session.context = createDto.context || {}; - session.lastMessageAt = new Date(); + const user = await UserModel.findById(userId); + if (!user) { + throw new NotFoundError("User not found"); + } - await em.persistAndFlush(session); + const session = await ChatSessionModel.create({ + title: createDto.title, + user: userId, + context: createDto.context || {}, + lastMessageAt: new Date(), + status: ChatSessionStatus.ACTIVE, + }); return this.mapSessionToDto(session); } - //************************************ */ + async getUserChatSessions(userId: string, limit = 10) { - const em = this.em.fork(); - const sessions = await this.chatSessionRepo.findByUserWithMessages(userId, limit, em); + if (!isValidObjectId(userId)) { + throw new BadRequestError("Invalid user ID"); + } + + const sessions = await this.chatSessionRepo.findByUserWithMessages(userId, limit); return sessions.map((session) => this.mapSessionToDto(session)); } - //************************************ */ async getChatSession(sessionId: string, userId: string) { - const em = this.em.fork(); - const session = await em.findOne(ChatSession, { id: sessionId, user: userId }, { populate: ["messages", "messages.sender"] }); + if (!isValidObjectId(sessionId) || !isValidObjectId(userId)) { + throw new BadRequestError("Invalid session or user ID"); + } + const session = await this.chatSessionRepo.findByUserAndId(sessionId, userId); if (!session) { - throw new NotFoundException("Chat session not found"); + throw new NotFoundError("Chat session not found"); } return this.mapSessionToDto(session); } - //************************************ */ async sendMessage(userId: string, sendDto: SendMessageDto, provider?: LLMProvider) { const selectedProvider = provider || this.defaultProvider; @@ -93,47 +99,44 @@ export class ChatbotService { } } - //************************************ */ - private async sendMessageWithGemini(userId: string, sendDto: SendMessageDto) { - const em = this.em.fork(); - const session = await em.findOne(ChatSession, { id: sendDto.sessionId, user: userId }, { populate: ["messages"] }); + if (!isValidObjectId(sendDto.sessionId) || !isValidObjectId(userId)) { + throw new BadRequestError("Invalid session or user ID"); + } + const session = await ChatSessionModel.findOne({ _id: sendDto.sessionId, user: userId }).populate("messages").lean(); if (!session) { - throw new NotFoundException("Chat session not found"); + throw new NotFoundError("Chat session not found"); } if (session.status !== ChatSessionStatus.ACTIVE) { - throw new BadRequestException("Cannot send message to inactive session"); + throw new BadRequestError("Cannot send message to inactive session"); } - const user = await em.findOne(User, { id: userId }); + const user = await UserModel.findById(userId); if (!user) { - throw new NotFoundException("User not found"); + throw new NotFoundError("User not found"); } // Create user message - const userMessage = new ChatMessage(); - userMessage.content = sendDto.content; - userMessage.type = MessageType.USER; - userMessage.session = session; - userMessage.sender = user; - userMessage.responseToId = sendDto.responseToId; - userMessage.metadata = sendDto.metadata; - - await em.persistAndFlush(userMessage); + const userMessage = await ChatMessageModel.create({ + content: sendDto.content, + type: MessageType.USER, + session: sendDto.sessionId, + sender: userId, + responseToId: sendDto.responseToId, + metadata: sendDto.metadata, + }); // Update session last message time - await this.chatSessionRepo.updateLastMessageTime(session.id, em); + await this.chatSessionRepo.updateLastMessageTime(sendDto.sessionId); // Generate bot response asynchronously using original Gemini service - this.generateBotResponse(session, userMessage, userId); + this.generateBotResponse(sendDto.sessionId, userMessage._id.toString(), userId); return this.mapMessageToDto(userMessage); } - //************************************ */ - async sendMessageStream(userId: string, sendDto: SendMessageDto, provider?: LLMProvider) { const selectedProvider = provider || this.defaultProvider; @@ -151,46 +154,45 @@ export class ChatbotService { } } - //************************************ */ - private async sendMessageStreamWithGemini(userId: string, sendDto: SendMessageDto) { - const em = this.em.fork(); - const session = await em.findOne(ChatSession, { id: sendDto.sessionId, user: userId }, { populate: ["messages"] }); + if (!isValidObjectId(sendDto.sessionId) || !isValidObjectId(userId)) { + throw new BadRequestError("Invalid session or user ID"); + } + const session = await ChatSessionModel.findOne({ _id: sendDto.sessionId, user: userId }).lean(); if (!session) { - throw new NotFoundException("Chat session not found"); + throw new NotFoundError("Chat session not found"); } if (session.status !== ChatSessionStatus.ACTIVE) { - throw new BadRequestException("Cannot send message to inactive session"); + throw new BadRequestError("Cannot send message to inactive session"); } - const user = await em.findOne(User, { id: userId }); + const user = await UserModel.findById(userId); if (!user) { - throw new NotFoundException("User not found"); + throw new NotFoundError("User not found"); } // Create user message - const userMessage = new ChatMessage(); - userMessage.content = sendDto.content; - userMessage.type = MessageType.USER; - userMessage.session = session; - userMessage.sender = user; - userMessage.responseToId = sendDto.responseToId; - userMessage.metadata = sendDto.metadata; - - await em.persistAndFlush(userMessage); + const userMessage = await ChatMessageModel.create({ + content: sendDto.content, + type: MessageType.USER, + session: sendDto.sessionId, + sender: userId, + responseToId: sendDto.responseToId, + metadata: sendDto.metadata, + }); // Update session last message time - await this.chatSessionRepo.updateLastMessageTime(session.id, em); + await this.chatSessionRepo.updateLastMessageTime(sendDto.sessionId); // Get conversation history for context - const history = await this.chatMessageRepo.getConversationHistory(session.id, 20, em); + const history = await this.chatMessageRepo.getConversationHistory(sendDto.sessionId, 20); // Build context for LLM const context: IChatContext = { userId, - sessionId: session.id, + sessionId: sendDto.sessionId, conversationHistory: history.reverse().map((msg) => ({ content: msg.content, type: msg.type as "user" | "bot" | "system", @@ -209,18 +211,20 @@ export class ChatbotService { }; } - //************************************ */ - - private async generateBotResponse(session: ChatSession, userMessage: ChatMessage, userId: string): Promise { - const em = this.em.fork(); + private async generateBotResponse(sessionId: string, userMessageId: string, userId: string): Promise { try { // Get conversation history - const history = await this.chatMessageRepo.getConversationHistory(session.id, 20, em); + const history = await this.chatMessageRepo.getConversationHistory(sessionId, 20); + + const session = await ChatSessionModel.findById(sessionId).lean(); + if (!session) { + throw new NotFoundError("Session not found"); + } // Build context for LLM const context: IChatContext = { userId, - sessionId: session.id, + sessionId, conversationHistory: history.reverse().map((msg) => ({ content: msg.content, type: msg.type as "user" | "bot" | "system", @@ -230,182 +234,183 @@ export class ChatbotService { userPreferences: session.context, }; + const userMessage = await ChatMessageModel.findById(userMessageId); + if (!userMessage) { + throw new NotFoundError("User message not found"); + } + // Generate response using LLM const llmResponse = await this.llmService.generateResponse(userMessage.content, context); // Create bot message - const botMessage = new ChatMessage(); - botMessage.content = llmResponse.message; - botMessage.type = MessageType.BOT; - botMessage.session = session; - botMessage.responseToId = userMessage.id; - botMessage.tokensUsed = llmResponse.tokensUsed; - botMessage.metadata = { - confidence: llmResponse.confidence, - sources: llmResponse.sources, - llmContext: llmResponse.context, - }; - - await em.persistAndFlush(botMessage); + const botMessage = await ChatMessageModel.create({ + content: llmResponse.message, + type: MessageType.BOT, + session: sessionId, + responseToId: userMessageId, + tokensUsed: llmResponse.tokensUsed, + metadata: { + confidence: llmResponse.confidence, + sources: llmResponse.sources, + llmContext: llmResponse.context, + }, + }); // Update session last message time - await this.chatSessionRepo.updateLastMessageTime(session.id, em); + await this.chatSessionRepo.updateLastMessageTime(sessionId); - this.logger.log(`Generated bot response for session ${session.id}`); + this.logger.info(`Generated bot response for session ${sessionId}`); } catch (error) { - this.logger.error(`Failed to generate bot response for session ${session.id}`, error); + this.logger.error(`Failed to generate bot response for session ${sessionId}`, error); // Create error message - const errorMessage = new ChatMessage(); - errorMessage.content = "متأسفم، در حال حاضر مشکلی در پاسخگویی دارم. لطفاً دوباره تلاش کنید. 🙏"; - errorMessage.type = MessageType.BOT; - errorMessage.session = session; - errorMessage.responseToId = userMessage.id; - errorMessage.status = MessageStatus.FAILED; - - await em.persistAndFlush(errorMessage); + await ChatMessageModel.create({ + content: "متأسفم، در حال حاضر مشکلی در پاسخگویی دارم. لطفاً دوباره تلاش کنید. 🙏", + type: MessageType.BOT, + session: sessionId, + responseToId: userMessageId, + status: MessageStatus.FAILED, + }); } } - //************************************ */ - async closeChatSession(sessionId: string, userId: string) { - const em = this.em.fork(); - const session = await em.findOne(ChatSession, { id: sessionId, user: userId }); - - if (!session) { - throw new NotFoundException("Chat session not found"); + if (!isValidObjectId(sessionId) || !isValidObjectId(userId)) { + throw new BadRequestError("Invalid session or user ID"); } - session.status = ChatSessionStatus.CLOSED; - await em.persistAndFlush(session); + const session = await ChatSessionModel.findOneAndUpdate( + { _id: sessionId, user: userId }, + { status: ChatSessionStatus.CLOSED }, + { new: true }, + ); + + if (!session) { + throw new NotFoundError("Chat session not found"); + } return { message: "Chat session closed successfully" }; } - //************************************ */ - async markMessagesAsRead(sessionId: string, userId: string, messageIds: string[]) { - const em = this.em.fork(); - const session = await em.findOne(ChatSession, { id: sessionId, user: userId }); - - if (!session) { - throw new NotFoundException("Chat session not found"); + if (!isValidObjectId(sessionId) || !isValidObjectId(userId)) { + throw new BadRequestError("Invalid session or user ID"); } - await this.chatMessageRepo.markAsRead(messageIds, em); + const session = await ChatSessionModel.findOne({ _id: sessionId, user: userId }); + if (!session) { + throw new NotFoundError("Chat session not found"); + } + + await this.chatMessageRepo.markAsRead(messageIds); return { message: "Messages marked as read successfully" }; } - //************************************ */ - - private mapSessionToDto(session: ChatSession) { + private mapSessionToDto(session: IChatSession | any) { + const sessionObj = session.toObject ? session.toObject() : session; return { - id: session.id, - title: session.title, - status: session.status, - createdAt: session.createdAt, - lastMessageAt: session.lastMessageAt, - context: session.context, - messages: session.messages ? session.messages.getItems().map((msg) => this.mapMessageToDto(msg)) : [], + id: sessionObj._id.toString(), + title: sessionObj.title, + status: sessionObj.status, + createdAt: sessionObj.createdAt, + lastMessageAt: sessionObj.lastMessageAt, + context: sessionObj.context, + messages: sessionObj.messages ? sessionObj.messages.map((msg: any) => this.mapMessageToDto(msg)) : [], }; } - //************************************ */ - - private mapMessageToDto(message: ChatMessage) { + private mapMessageToDto(message: IChatMessage | any) { + const messageObj = message.toObject ? message.toObject() : message; return { - id: message.id, - content: message.content, - type: message.type, - status: message.status, - createdAt: message.createdAt, - responseToId: message.responseToId, - metadata: message.metadata, - tokensUsed: message.tokensUsed, + id: messageObj._id.toString(), + content: messageObj.content, + type: messageObj.type, + status: messageObj.status, + createdAt: messageObj.createdAt, + responseToId: messageObj.responseToId, + metadata: messageObj.metadata, + tokensUsed: messageObj.tokensUsed, }; } - //************************************ */ - async sendMessageWithLangChain(userId: string, sendDto: SendMessageDto) { - const em = this.em.fork(); - const session = await em.findOne(ChatSession, { id: sendDto.sessionId, user: userId }, { populate: ["messages"] }); + if (!isValidObjectId(sendDto.sessionId) || !isValidObjectId(userId)) { + throw new BadRequestError("Invalid session or user ID"); + } + const session = await ChatSessionModel.findOne({ _id: sendDto.sessionId, user: userId }).lean(); if (!session) { - throw new NotFoundException("Chat session not found"); + throw new NotFoundError("Chat session not found"); } if (session.status !== ChatSessionStatus.ACTIVE) { - throw new BadRequestException("Cannot send message to inactive session"); + throw new BadRequestError("Cannot send message to inactive session"); } - const user = await em.findOne(User, { id: userId }); + const user = await UserModel.findById(userId); if (!user) { - throw new NotFoundException("User not found"); + throw new NotFoundError("User not found"); } // Create user message - const userMessage = new ChatMessage(); - userMessage.content = sendDto.content; - userMessage.type = MessageType.USER; - userMessage.session = session; - userMessage.sender = user; - userMessage.responseToId = sendDto.responseToId; - userMessage.metadata = sendDto.metadata; - - await em.persistAndFlush(userMessage); + const userMessage = await ChatMessageModel.create({ + content: sendDto.content, + type: MessageType.USER, + session: sendDto.sessionId, + sender: userId, + responseToId: sendDto.responseToId, + metadata: sendDto.metadata, + }); // Update session last message time - await this.chatSessionRepo.updateLastMessageTime(session.id, em); + await this.chatSessionRepo.updateLastMessageTime(sendDto.sessionId); // Generate bot response using LangChain - this.generateBotResponseWithLangChain(session, userMessage, userId); + this.generateBotResponseWithLangChain(sendDto.sessionId, userMessage._id.toString(), userId); return this.mapMessageToDto(userMessage); } - //************************************ */ - async sendMessageStreamWithLangChain(userId: string, sendDto: SendMessageDto) { - const em = this.em.fork(); - const session = await em.findOne(ChatSession, { id: sendDto.sessionId, user: userId }, { populate: ["messages"] }); + if (!isValidObjectId(sendDto.sessionId) || !isValidObjectId(userId)) { + throw new BadRequestError("Invalid session or user ID"); + } + const session = await ChatSessionModel.findOne({ _id: sendDto.sessionId, user: userId }).lean(); if (!session) { - throw new NotFoundException("Chat session not found"); + throw new NotFoundError("Chat session not found"); } if (session.status !== ChatSessionStatus.ACTIVE) { - throw new BadRequestException("Cannot send message to inactive session"); + throw new BadRequestError("Cannot send message to inactive session"); } - const user = await em.findOne(User, { id: userId }); + const user = await UserModel.findById(userId); if (!user) { - throw new NotFoundException("User not found"); + throw new NotFoundError("User not found"); } // Create user message - const userMessage = new ChatMessage(); - userMessage.content = sendDto.content; - userMessage.type = MessageType.USER; - userMessage.session = session; - userMessage.sender = user; - userMessage.responseToId = sendDto.responseToId; - userMessage.metadata = sendDto.metadata; - - await em.persistAndFlush(userMessage); + const userMessage = await ChatMessageModel.create({ + content: sendDto.content, + type: MessageType.USER, + session: sendDto.sessionId, + sender: userId, + responseToId: sendDto.responseToId, + metadata: sendDto.metadata, + }); // Update session last message time - await this.chatSessionRepo.updateLastMessageTime(session.id, em); + await this.chatSessionRepo.updateLastMessageTime(sendDto.sessionId); // Get conversation history for context - const history = await this.chatMessageRepo.getConversationHistory(session.id, 20, em); + const history = await this.chatMessageRepo.getConversationHistory(sendDto.sessionId, 20); // Build context for LangChain const context: IChatContext = { userId, - sessionId: session.id, + sessionId: sendDto.sessionId, conversationHistory: history.reverse().map((msg) => ({ content: msg.content, type: msg.type as "user" | "bot" | "system", @@ -424,18 +429,20 @@ export class ChatbotService { }; } - //************************************ */ - - private async generateBotResponseWithLangChain(session: ChatSession, userMessage: ChatMessage, userId: string): Promise { - const em = this.em.fork(); + private async generateBotResponseWithLangChain(sessionId: string, userMessageId: string, userId: string): Promise { try { // Get conversation history - const history = await this.chatMessageRepo.getConversationHistory(session.id, 20, em); + const history = await this.chatMessageRepo.getConversationHistory(sessionId, 20); + + const session = await ChatSessionModel.findById(sessionId).lean(); + if (!session) { + throw new NotFoundError("Session not found"); + } // Build context for LangChain const context: IChatContext = { userId, - sessionId: session.id, + sessionId, conversationHistory: history.reverse().map((msg) => ({ content: msg.content, type: msg.type as "user" | "bot" | "system", @@ -445,51 +452,51 @@ export class ChatbotService { userPreferences: session.context, }; + const userMessage = await ChatMessageModel.findById(userMessageId); + if (!userMessage) { + throw new NotFoundError("User message not found"); + } + // Generate response using LangChain const langChainResponse = await this.langChainService.generateResponse(userMessage.content, context); // Create bot message - const botMessage = new ChatMessage(); - botMessage.content = langChainResponse.message; - botMessage.type = MessageType.BOT; - botMessage.session = session; - botMessage.responseToId = userMessage.id; - botMessage.tokensUsed = langChainResponse.tokensUsed; - botMessage.metadata = { - confidence: langChainResponse.confidence, - sources: langChainResponse.sources, - llmContext: langChainResponse.context, - provider: "langchain", - documentsRetrieved: langChainResponse.context?.documentsRetrieved || 0, - }; - - await em.persistAndFlush(botMessage); + const botMessage = await ChatMessageModel.create({ + content: langChainResponse.message, + type: MessageType.BOT, + session: sessionId, + responseToId: userMessageId, + tokensUsed: langChainResponse.tokensUsed, + metadata: { + confidence: langChainResponse.confidence, + sources: langChainResponse.sources, + llmContext: langChainResponse.context, + provider: "langchain", + documentsRetrieved: langChainResponse.context?.documentsRetrieved || 0, + }, + }); // Update session last message time - await this.chatSessionRepo.updateLastMessageTime(session.id, em); + await this.chatSessionRepo.updateLastMessageTime(sessionId); - this.logger.log(`Generated LangChain bot response for session ${session.id}`); + this.logger.info(`Generated LangChain bot response for session ${sessionId}`); } catch (error) { - this.logger.error(`Failed to generate LangChain bot response for session ${session.id}`, error); + this.logger.error(`Failed to generate LangChain bot response for session ${sessionId}`, error); // Fallback to regular LLM service - this.logger.log(`Falling back to regular LLM service for session ${session.id}`); - await this.generateBotResponse(session, userMessage, userId); + this.logger.info(`Falling back to regular LLM service for session ${sessionId}`); + await this.generateBotResponse(sessionId, userMessageId, userId); } } - //************************************ */ - async refreshLangChainData() { try { await this.langChainService.refreshVectorStore(); - this.logger.log("LangChain vector store refreshed successfully"); + this.logger.info("LangChain vector store refreshed successfully"); return { message: "LangChain vector store refreshed successfully" }; } catch (error) { this.logger.error("Failed to refresh LangChain vector store", error); throw new Error("Failed to refresh training data"); } } - - //************************************ */ } diff --git a/src/modules/chatbot/providers/data-context.service.ts b/src/modules/chatbot/providers/data-context.service.ts index 00c6138..d8fa26d 100644 --- a/src/modules/chatbot/providers/data-context.service.ts +++ b/src/modules/chatbot/providers/data-context.service.ts @@ -1,22 +1,19 @@ -import { EntityManager } from "@mikro-orm/core"; -import { Injectable, Logger } from "@nestjs/common"; +import { injectable } from "inversify"; -import { Business } from "../../businesses/entities/business.entity"; -import { Company } from "../../companies/entities/company.entity"; -import { Industry } from "../../industries/entities/industry.entity"; -import { Invoice } from "../../invoices/entities/invoice.entity"; -import { InvoiceStatus } from "../../invoices/enums/invoice-status.enum"; -import { Payment } from "../../payments/entities/payment.entity"; -import { PaymentStatus } from "../../payments/enums/payment-status.enum"; -import { Ticket } from "../../tickets/entities/ticket.entity"; -import { TicketStatus } from "../../tickets/enums/ticket-status.enum"; import { IChatContext } from "../interfaces/chatbot.interface"; +import { Logger } from "../../../core/logging/logger"; -@Injectable() +/** + * Service for retrieving relevant context data from the database + * This is a simplified version - can be extended to fetch actual data from your database models + */ +@injectable() export class DataContextService { - private readonly logger = new Logger(DataContextService.name); + private readonly logger: Logger; - constructor(private em: EntityManager) {} + constructor() { + this.logger = new Logger("DataContextService"); + } async getRelevantContext(message: string, context: IChatContext): Promise<{ data: string[]; sources: string[] }> { const relevantData: string[] = []; @@ -26,15 +23,22 @@ export class DataContextService { // Analyze message to determine what data to fetch const keywords = this.extractKeywords(message); - // Fetch relevant data based on keywords and user context - await Promise.all([ - this.getBusinessData(keywords, context.userId, relevantData, sources), - this.getInvoiceData(keywords, context.userId, relevantData, sources), - this.getPaymentData(keywords, context.userId, relevantData, sources), - this.getCompanyData(keywords, context.userId, relevantData, sources), - this.getTicketData(keywords, context.userId, relevantData, sources), - this.getIndustryData(keywords, relevantData, sources), - ]); + // TODO: Implement actual data fetching based on your project's models + // For now, this returns empty data - you can extend this to fetch: + // - Product data from ProductModel + // - Category data from CategoryModel + // - Order data from OrderModel + // - User data from UserModel + // etc. + + // Example: If you want to fetch product data + // if (this.hasProductKeywords(keywords)) { + // const products = await ProductModel.find({ ... }).limit(5); + // products.forEach((product) => { + // relevantData.push(`Product: ${product.title_fa} - Price: ${product.price}`); + // sources.push("Products Database"); + // }); + // } return { data: relevantData, sources }; } catch (error) { @@ -53,174 +57,19 @@ export class DataContextService { .filter((word) => word.length > 2 && !commonWords.includes(word)); } - private async getBusinessData(keywords: string[], userId: string, relevantData: string[], sources: string[]): Promise { - if (this.hasBusinessKeywords(keywords)) { - try { - const businesses = await this.em.find(Business, { danakSubscriptionId: userId }, { limit: 5, populate: ["users"] }); - - businesses.forEach((business) => { - relevantData.push( - `Business: ${business.name} - Domain: ${business.domain || "No domain"} - Verified: ${business.isDomainVerified ? "Yes" : "No"}`, - ); - sources.push("Businesses Database"); - }); - } catch (error) { - this.logger.warn("Failed to fetch business data", error); - } - } + // Helper methods for keyword detection - can be extended + private hasProductKeywords(keywords: string[]): boolean { + const productTerms = ["product", "item", "goods", "merchandise", "کالا", "محصول"]; + return keywords.some((keyword) => productTerms.includes(keyword)); } - private async getInvoiceData(keywords: string[], userId: string, relevantData: string[], sources: string[]): Promise { - if (this.hasInvoiceKeywords(keywords)) { - try { - // Find user's businesses first - const userBusinesses = await this.em.find(Business, { danakSubscriptionId: userId }); - - if (userBusinesses.length > 0) { - const businessIds = userBusinesses.map((b) => b.id); - const invoices = await this.em.find(Invoice, { business: { $in: businessIds } }, { limit: 10, orderBy: { createdAt: "DESC" } }); - - const summary = { - total: invoices.length, - paid: invoices.filter((inv) => inv.status === InvoiceStatus.PAID).length, - pending: invoices.filter((inv) => inv.status === InvoiceStatus.PENDING).length, - totalAmount: invoices.reduce((sum, inv) => sum + Number(inv.totalPrice), 0), - }; - - relevantData.push( - `Invoice Summary: Total invoices: ${summary.total}, Paid: ${summary.paid}, Pending: ${summary.pending}, Total amount: $${summary.totalAmount}`, - ); - sources.push("Invoices Database"); - } - } catch (error) { - this.logger.warn("Failed to fetch invoice data", error); - } - } + private hasOrderKeywords(keywords: string[]): boolean { + const orderTerms = ["order", "purchase", "buy", "cart", "سفارش", "خرید"]; + return keywords.some((keyword) => orderTerms.includes(keyword)); } - private async getPaymentData(keywords: string[], userId: string, relevantData: string[], sources: string[]): Promise { - if (this.hasPaymentKeywords(keywords)) { - try { - // Find user's businesses first - const userBusinesses = await this.em.find(Business, { danakSubscriptionId: userId }); - - if (userBusinesses.length > 0) { - const businessIds = userBusinesses.map((b) => b.id); - const payments = await this.em.find(Payment, { business: { $in: businessIds } }, { limit: 10, orderBy: { createdAt: "DESC" } }); - - const summary = { - total: payments.length, - successful: payments.filter((pay) => pay.status === PaymentStatus.COMPLETED).length, - failed: payments.filter((pay) => pay.status === PaymentStatus.FAILED).length, - totalAmount: payments.reduce((sum, pay) => sum + Number(pay.amount), 0), - }; - - relevantData.push( - `Payment Summary: Total payments: ${summary.total}, Successful: ${summary.successful}, Failed: ${summary.failed}, Total amount: $${summary.totalAmount}`, - ); - sources.push("Payments Database"); - } - } catch (error) { - this.logger.warn("Failed to fetch payment data", error); - } - } - } - - private async getCompanyData(keywords: string[], userId: string, relevantData: string[], sources: string[]): Promise { - if (this.hasCompanyKeywords(keywords)) { - try { - // Find user's businesses first - const userBusinesses = await this.em.find(Business, { danakSubscriptionId: userId }); - - if (userBusinesses.length > 0) { - const businessIds = userBusinesses.map((b) => b.id); - const companies = await this.em.find(Company, { business: { $in: businessIds } }, { limit: 5, populate: ["industry"] }); - - companies.forEach((company) => { - relevantData.push(`Company: ${company.name} - Industry: ${company.industry?.title || "N/A"} - Status: ${company.status}`); - sources.push("Companies Database"); - }); - } - } catch (error) { - this.logger.warn("Failed to fetch company data", error); - } - } - } - - private async getTicketData(keywords: string[], userId: string, relevantData: string[], sources: string[]): Promise { - if (this.hasTicketKeywords(keywords)) { - try { - // Find user's businesses first - const userBusinesses = await this.em.find(Business, { danakSubscriptionId: userId }); - - if (userBusinesses.length > 0) { - const businessIds = userBusinesses.map((b) => b.id); - const tickets = await this.em.find( - Ticket, - { business: { $in: businessIds } }, - { limit: 10, orderBy: { createdAt: "DESC" }, populate: ["category"] }, - ); - - const summary = { - total: tickets.length, - pending: tickets.filter((t) => t.status === TicketStatus.PENDING).length, - answered: tickets.filter((t) => t.status === TicketStatus.ANSWERED).length, - closed: tickets.filter((t) => t.status === TicketStatus.CLOSED).length, - categories: [...new Set(tickets.map((t) => t.category?.title || "Uncategorized"))], - }; - - relevantData.push( - `Support Tickets: Total: ${summary.total}, Pending: ${summary.pending}, Answered: ${summary.answered}, Closed: ${summary.closed}, Categories: ${summary.categories.join(", ")}`, - ); - sources.push("Support Tickets Database"); - } - } catch (error) { - this.logger.warn("Failed to fetch ticket data", error); - } - } - } - - private async getIndustryData(keywords: string[], relevantData: string[], sources: string[]): Promise { - if (this.hasIndustryKeywords(keywords)) { - try { - const industries = await this.em.find(Industry, {}, { limit: 10 }); - - const industryInfo = industries.map((ind) => `${ind.title}: ${ind.isActive ? "Active" : "Inactive"}`); - relevantData.push(`Available Industries: ${industryInfo.join("; ")}`); - sources.push("Industries Database"); - } catch (error) { - this.logger.warn("Failed to fetch industry data", error); - } - } - } - - private hasBusinessKeywords(keywords: string[]): boolean { - const businessTerms = ["business", "company", "startup", "enterprise", "organization", "firm"]; - return keywords.some((keyword) => businessTerms.includes(keyword)); - } - - private hasInvoiceKeywords(keywords: string[]): boolean { - const invoiceTerms = ["invoice", "bill", "billing", "payment", "charge", "fee", "cost", "price"]; - return keywords.some((keyword) => invoiceTerms.includes(keyword)); - } - - private hasPaymentKeywords(keywords: string[]): boolean { - const paymentTerms = ["payment", "pay", "transaction", "money", "refund", "charge", "card", "bank"]; - return keywords.some((keyword) => paymentTerms.includes(keyword)); - } - - private hasCompanyKeywords(keywords: string[]): boolean { - const companyTerms = ["company", "corporation", "business", "organization", "enterprise"]; - return keywords.some((keyword) => companyTerms.includes(keyword)); - } - - private hasTicketKeywords(keywords: string[]): boolean { - const ticketTerms = ["ticket", "support", "help", "issue", "problem", "bug", "error"]; - return keywords.some((keyword) => ticketTerms.includes(keyword)); - } - - private hasIndustryKeywords(keywords: string[]): boolean { - const industryTerms = ["industry", "sector", "field", "domain", "market"]; - return keywords.some((keyword) => industryTerms.includes(keyword)); + private hasCategoryKeywords(keywords: string[]): boolean { + const categoryTerms = ["category", "type", "kind", "دسته", "دسته‌بندی"]; + return keywords.some((keyword) => categoryTerms.includes(keyword)); } } diff --git a/src/modules/chatbot/providers/langchain.service.ts b/src/modules/chatbot/providers/langchain.service.ts index 4bbc174..39c0af6 100644 --- a/src/modules/chatbot/providers/langchain.service.ts +++ b/src/modules/chatbot/providers/langchain.service.ts @@ -3,38 +3,38 @@ import { StringOutputParser } from "@langchain/core/output_parsers"; import { PromptTemplate } from "@langchain/core/prompts"; import { RunnableSequence } from "@langchain/core/runnables"; import { ChatGoogleGenerativeAI, GoogleGenerativeAIEmbeddings } from "@langchain/google-genai"; -import { EntityManager } from "@mikro-orm/core"; -import { Injectable, Logger, OnModuleInit } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; +import { injectable } from "inversify"; import { RecursiveCharacterTextSplitter } from "langchain/text_splitter"; import { MemoryVectorStore } from "langchain/vectorstores/memory"; -import { Company } from "../../companies/entities/company.entity"; -import { Industry } from "../../industries/entities/industry.entity"; import { CHATBOT_CONSTANTS } from "../constants/chatbot.constants"; import { IChatContext, IChatbotResponse } from "../interfaces/chatbot.interface"; +import { Logger } from "../../../core/logging/logger"; -@Injectable() -export class LangChainService implements OnModuleInit { - private readonly logger = new Logger(LangChainService.name); +@injectable() +export class LangChainService { + private readonly logger: Logger; private embeddings: GoogleGenerativeAIEmbeddings; private llm: ChatGoogleGenerativeAI; private vectorStore: MemoryVectorStore; private textSplitter: RecursiveCharacterTextSplitter; private isInitialized = false; - constructor( - private em: EntityManager, - private configService: ConfigService, - ) { + constructor() { + this.logger = new Logger("LangChainService"); + const apiKey = process.env.GEMINI_API_KEY; + if (!apiKey) { + throw new Error("GEMINI_API_KEY is required"); + } + // Initialize Google Gemini components this.embeddings = new GoogleGenerativeAIEmbeddings({ - apiKey: this.configService.getOrThrow("GEMINI_API_KEY"), + apiKey, modelName: "embedding-001", // Google's embedding model }); this.llm = new ChatGoogleGenerativeAI({ - apiKey: this.configService.getOrThrow("GEMINI_API_KEY"), + apiKey, model: CHATBOT_CONSTANTS.DEFAULT_MODEL, temperature: CHATBOT_CONSTANTS.DEFAULT_TEMPERATURE, maxOutputTokens: CHATBOT_CONSTANTS.DEFAULT_MAX_TOKENS, @@ -45,18 +45,20 @@ export class LangChainService implements OnModuleInit { chunkOverlap: 200, separators: ["\n\n", "\n", ".", "!", "?", "؟", "!", ".", " ", ""], }); - } - async onModuleInit() { - await this.initializeVectorStore(); + // Initialize vector store asynchronously + this.initializeVectorStore().catch((error) => { + this.logger.error("Failed to initialize vector store", error); + }); } private async initializeVectorStore() { try { - this.logger.log(CHATBOT_CONSTANTS.LANGCHAIN_MESSAGES.LOADING_DATA); + this.logger.info(CHATBOT_CONSTANTS.LANGCHAIN_MESSAGES.LOADING_DATA); - // Load company and industry data for training - const documents = await this.loadCompanyAndIndustryDocuments(); + // Load documents for training + // TODO: Load actual data from your database models (Product, Category, etc.) + const documents = await this.loadDocuments(); if (documents.length === 0) { this.logger.warn(CHATBOT_CONSTANTS.LANGCHAIN_MESSAGES.NO_DATA_FOUND); @@ -68,7 +70,7 @@ export class LangChainService implements OnModuleInit { // Split documents into chunks const splitDocs = await this.textSplitter.splitDocuments(documents); - this.logger.log( + this.logger.info( CHATBOT_CONSTANTS.LANGCHAIN_MESSAGES.DOCUMENTS_SPLIT.replace("{totalDocs}", documents.length.toString()).replace( "{chunks}", splitDocs.length.toString(), @@ -79,7 +81,7 @@ export class LangChainService implements OnModuleInit { this.vectorStore = await MemoryVectorStore.fromDocuments(splitDocs, this.embeddings); this.isInitialized = true; - this.logger.log(CHATBOT_CONSTANTS.LANGCHAIN_MESSAGES.VECTOR_STORE_READY); + this.logger.info(CHATBOT_CONSTANTS.LANGCHAIN_MESSAGES.VECTOR_STORE_READY); } catch (error) { this.logger.error(CHATBOT_CONSTANTS.LANGCHAIN_MESSAGES.VECTOR_STORE_ERROR, error); // Fallback to empty vector store @@ -88,131 +90,21 @@ export class LangChainService implements OnModuleInit { } } - private async loadCompanyAndIndustryDocuments(): Promise { + private async loadDocuments(): Promise { const documents: Document[] = []; - const em = this.em.fork(); try { - // Load companies data with their products and services - const companies = await em.find(Company, { isActive: true, deletedAt: null }, { populate: ["industry", "business", "products", "services"] }); - - companies.forEach((company) => { - // Company basic information using template - const companyContent = this.replaceTemplate(CHATBOT_CONSTANTS.COMPANY_DATA_TEMPLATES.COMPANY_INFO, { - name: company.name, - ceo: company.chiefExecutiveOfficer, - email: company.email, - phone: company.phone, - registrationNumber: company.identificationNumber, - establishmentDate: new Date(company.dateOfEstablishment).toLocaleDateString("fa-IR"), - address: company.address, - website: company.websiteUrl || CHATBOT_CONSTANTS.DEFAULT_VALUES.NO_WEBSITE, - description: company.description, - industry: company.industry?.title || CHATBOT_CONSTANTS.DEFAULT_VALUES.UNSPECIFIED, - status: company.status, - business: company.business?.name || CHATBOT_CONSTANTS.DEFAULT_VALUES.UNSPECIFIED, - }); - - documents.push( - new Document({ - pageContent: companyContent, - metadata: { - type: "company", - id: company.id, - name: company.name, - industry: company.industry?.title, - businessId: company.business?.id, - status: company.status, - }, - }), - ); - - // Company products using template - if (company.products && company.products.getItems().length > 0) { - company.products.getItems().forEach((product) => { - const productContent = this.replaceTemplate(CHATBOT_CONSTANTS.COMPANY_DATA_TEMPLATES.COMPANY_PRODUCT, { - companyName: company.name, - productTitle: product.title, - industry: company.industry?.title || CHATBOT_CONSTANTS.DEFAULT_VALUES.UNSPECIFIED, - companyDescription: company.description, - companyAddress: company.address, - companyPhone: company.phone, - companyEmail: company.email, - }); - - documents.push( - new Document({ - pageContent: productContent, - metadata: { - type: "product", - id: product.id, - title: product.title, - companyId: company.id, - companyName: company.name, - industry: company.industry?.title, - }, - }), - ); - }); - } - - // Company services using template - if (company.services && company.services.getItems().length > 0) { - company.services.getItems().forEach((service) => { - const serviceContent = this.replaceTemplate(CHATBOT_CONSTANTS.COMPANY_DATA_TEMPLATES.COMPANY_SERVICE, { - companyName: company.name, - serviceTitle: service.title, - industry: company.industry?.title || CHATBOT_CONSTANTS.DEFAULT_VALUES.UNSPECIFIED, - companyDescription: company.description, - companyAddress: company.address, - companyPhone: company.phone, - companyEmail: company.email, - }); - - documents.push( - new Document({ - pageContent: serviceContent, - metadata: { - type: "service", - id: service.id, - title: service.title, - companyId: company.id, - companyName: company.name, - industry: company.industry?.title, - }, - }), - ); - }); - } - }); - - // Load industries data using template - const industries = await em.find(Industry, { isActive: true, deletedAt: null }, { populate: ["companies", "business"] }); - industries.forEach((industry) => { - const companiesInIndustry = industry.companies?.getItems().filter((c) => c.isActive && !c.deletedAt) || []; - - const industryContent = this.replaceTemplate(CHATBOT_CONSTANTS.COMPANY_DATA_TEMPLATES.INDUSTRY_INFO, { - title: industry.title, - status: industry.isActive ? CHATBOT_CONSTANTS.DEFAULT_VALUES.ACTIVE : CHATBOT_CONSTANTS.DEFAULT_VALUES.INACTIVE, - companiesCount: companiesInIndustry.length.toString(), - business: industry.business?.name || CHATBOT_CONSTANTS.DEFAULT_VALUES.UNSPECIFIED, - companiesList: companiesInIndustry.map((company) => `- ${company.name}`).join("\n"), - }); - - documents.push( - new Document({ - pageContent: industryContent, - metadata: { - type: "industry", - id: industry.id, - title: industry.title, - isActive: industry.isActive, - companiesCount: companiesInIndustry.length, - businessId: industry.business?.id, - }, - }), - ); - }); + // TODO: Load actual data from your database + // Example: + // const products = await ProductModel.find({ ... }).limit(100); + // products.forEach((product) => { + // documents.push( + // new Document({ + // pageContent: `Product: ${product.title_fa} - Description: ${product.description}`, + // metadata: { type: "product", id: product._id.toString() }, + // }), + // ); + // }); // Add comprehensive Farsi guidance documents from constants const companyGuidanceDocuments = [ @@ -252,7 +144,7 @@ export class LangChainService implements OnModuleInit { documents.push(...companyGuidanceDocuments); - this.logger.log(CHATBOT_CONSTANTS.LANGCHAIN_MESSAGES.DOCUMENTS_LOADED.replace("{count}", documents.length.toString())); + this.logger.info(CHATBOT_CONSTANTS.LANGCHAIN_MESSAGES.DOCUMENTS_LOADED.replace("{count}", documents.length.toString())); return documents; } catch (error) { this.logger.error(CHATBOT_CONSTANTS.LANGCHAIN_MESSAGES.LOADING_ERROR, error); @@ -363,7 +255,8 @@ export class LangChainService implements OnModuleInit { } async refreshVectorStore(): Promise { - this.logger.log(CHATBOT_CONSTANTS.LANGCHAIN_MESSAGES.REFRESHING_VECTOR_STORE); + this.logger.info(CHATBOT_CONSTANTS.LANGCHAIN_MESSAGES.REFRESHING_VECTOR_STORE); + this.isInitialized = false; await this.initializeVectorStore(); } @@ -372,17 +265,6 @@ export class LangChainService implements OnModuleInit { return Math.ceil(text.length / 4); } - /** - * Replace placeholders in template with actual values - */ - private replaceTemplate(template: string, values: Record): string { - let result = template; - Object.entries(values).forEach(([key, value]) => { - result = result.replace(new RegExp(`{${key}}`, "g"), value); - }); - return result; - } - get initialized(): boolean { return this.isInitialized; } diff --git a/src/modules/chatbot/providers/llm.service.ts b/src/modules/chatbot/providers/llm.service.ts index 4640fa2..e33f601 100644 --- a/src/modules/chatbot/providers/llm.service.ts +++ b/src/modules/chatbot/providers/llm.service.ts @@ -1,29 +1,32 @@ import { GoogleGenerativeAI, HarmBlockThreshold, HarmCategory } from "@google/generative-ai"; -import { Injectable, Logger } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; +import { inject, injectable } from "inversify"; import { DataContextService } from "./data-context.service"; +import { Logger } from "../../../core/logging/logger"; +import { IOCTYPES } from "../../../IOC/ioc.types"; import { CHATBOT_CONSTANTS } from "../constants/chatbot.constants"; import { IChatContext, IChatbotResponse, ILLMConfig } from "../interfaces/chatbot.interface"; -@Injectable() +@injectable() export class LLMService { - private readonly logger = new Logger(LLMService.name); + private readonly logger: Logger; private genAI: GoogleGenerativeAI; private config: ILLMConfig; - constructor( - private configService: ConfigService, - private dataContextService: DataContextService, - ) { + constructor(@inject(IOCTYPES.ChatbotDataContextService) private dataContextService: DataContextService) { + this.logger = new Logger("LLMService"); this.config = { - model: this.configService.get("GEMINI_MODEL", CHATBOT_CONSTANTS.DEFAULT_MODEL), - temperature: Number(this.configService.get("GEMINI_TEMPERATURE", CHATBOT_CONSTANTS.DEFAULT_TEMPERATURE)), - maxTokens: Number(this.configService.get("GEMINI_MAX_TOKENS", CHATBOT_CONSTANTS.DEFAULT_MAX_TOKENS)), - topP: Number(this.configService.get("GEMINI_TOP_P", 0.95)), - apiKey: this.configService.getOrThrow("GEMINI_API_KEY"), + model: process.env.GEMINI_MODEL || CHATBOT_CONSTANTS.DEFAULT_MODEL, + temperature: Number(process.env.GEMINI_TEMPERATURE || CHATBOT_CONSTANTS.DEFAULT_TEMPERATURE), + maxTokens: Number(process.env.GEMINI_MAX_TOKENS || CHATBOT_CONSTANTS.DEFAULT_MAX_TOKENS), + topP: Number(process.env.GEMINI_TOP_P || "0.95"), + apiKey: process.env.GEMINI_API_KEY || "", }; + if (!this.config.apiKey) { + throw new Error("GEMINI_API_KEY is required"); + } + this.genAI = new GoogleGenerativeAI(this.config.apiKey); } @@ -162,7 +165,9 @@ export class LLMService { } const history = []; - const recentHistory = context.conversationHistory.slice(-CHATBOT_CONSTANTS.MAX_CONVERSATION_HISTORY).filter((msg) => msg.type !== "system"); + const recentHistory = context.conversationHistory + .slice(-CHATBOT_CONSTANTS.MAX_CONVERSATION_HISTORY) + .filter((msg) => msg.type !== "system"); for (const msg of recentHistory) { history.push({ diff --git a/src/modules/chatbot/providers/websocket-auth.service.ts b/src/modules/chatbot/providers/websocket-auth.service.ts index e4847bd..52c9ace 100644 --- a/src/modules/chatbot/providers/websocket-auth.service.ts +++ b/src/modules/chatbot/providers/websocket-auth.service.ts @@ -1,10 +1,12 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { JwtService } from "@nestjs/jwt"; +import { inject, injectable } from "inversify"; import { Socket } from "socket.io"; import { WebSocketMessage } from "../../../common/enums/message.enum"; -import { ITokenPayload } from "../../auth/interfaces/IToken-payload"; -import { extractTokenFromClient } from "../../utils/providers/extract-token.utils"; +import { AuthTokenPayload } from "../../../common/types/jwt.type"; +import { jwtExpiredErr } from "../../../core/app/app.errors"; +import { Logger } from "../../../core/logging/logger"; +import { IOCTYPES } from "../../../IOC/ioc.types"; +import { TokenService } from "../../token/token.service"; import { WEBSOCKET_EVENTS } from "../constants/chatbot.constants"; import { AuthenticationResult, WebSocketErrorContext } from "../interfaces/websocket.interface"; @@ -12,11 +14,35 @@ import { AuthenticationResult, WebSocketErrorContext } from "../interfaces/webso * Service responsible for WebSocket authentication logic * Follows single responsibility principle and provides reusable authentication methods */ -@Injectable() +@injectable() export class WebSocketAuthService { - private readonly logger = new Logger(WebSocketAuthService.name); + private readonly logger: Logger; - constructor(private readonly jwtService: JwtService) {} + constructor(@inject(IOCTYPES.TokenService) private readonly tokenService: TokenService) { + this.logger = new Logger("WebSocketAuthService"); + } + + /** + * Extracts token from WebSocket client + */ + private extractTokenFromClient(client: Socket): string | null { + // Try to get token from query parameters + const token = client.handshake.query.token as string; + if (token) { + return token; + } + + // Try to get token from auth header + const authHeader = client.handshake.headers.authorization; + if (authHeader && typeof authHeader === "string") { + const parts = authHeader.split(" "); + if (parts.length === 2 && parts[0] === "Bearer") { + return parts[1]; + } + } + + return null; + } /** * Authenticates a WebSocket client and returns the result @@ -26,7 +52,7 @@ export class WebSocketAuthService { */ async authenticateClient(client: Socket): Promise { try { - const token = extractTokenFromClient(client); + const token = this.extractTokenFromClient(client); if (!token) { this.logAuthenticationAttempt(client, false, "No token provided"); @@ -36,9 +62,9 @@ export class WebSocketAuthService { }; } - const payload = await this.jwtService.verifyAsync(token); + const payload = this.tokenService.verifyToken(token); - if (!payload?.id) { + if (!payload?.sub) { this.logAuthenticationAttempt(client, false, "Invalid token payload"); return { success: false, @@ -46,14 +72,14 @@ export class WebSocketAuthService { }; } - // Store user data in client - client.data.user = payload; + // Store user data in client (convert sub to id for compatibility) + client.data.user = { id: payload.sub, sub: payload.sub }; - this.logAuthenticationAttempt(client, true, undefined, payload.id); + this.logAuthenticationAttempt(client, true, undefined, payload.sub); return { success: true, - user: payload, + user: { id: payload.sub, sub: payload.sub }, }; } catch (error) { const errorMessage = this.getAuthErrorMessage(error); @@ -90,7 +116,7 @@ export class WebSocketAuthService { * @param client - Socket.IO client * @param user - Authenticated user payload */ - emitAuthenticationSuccess(client: Socket, user: ITokenPayload): void { + emitAuthenticationSuccess(client: Socket, user: { id: string; sub: string }): void { client.emit(WEBSOCKET_EVENTS.AUTHENTICATED, { message: WebSocketMessage.AUTHENTICATED, userId: user.id, @@ -151,7 +177,7 @@ export class WebSocketAuthService { }; if (success) { - this.logger.log(`WebSocket authentication successful`, logData); + this.logger.info(`WebSocket authentication successful`, logData); } else { this.logger.warn(`WebSocket authentication failed`, logData); } @@ -161,7 +187,7 @@ export class WebSocketAuthService { * Maps JWT errors to user-friendly messages */ private getAuthErrorMessage(error: any): string { - if (error?.name === "TokenExpiredError") { + if (error instanceof jwtExpiredErr || error?.name === "TokenExpiredError") { return WebSocketMessage.TOKEN_EXPIRED; } diff --git a/src/modules/chatbot/repositories/chat-message.repository.ts b/src/modules/chatbot/repositories/chat-message.repository.ts index 2b8f17c..3540430 100644 --- a/src/modules/chatbot/repositories/chat-message.repository.ts +++ b/src/modules/chatbot/repositories/chat-message.repository.ts @@ -1,34 +1,37 @@ -import { EntityManager, EntityRepository } from "@mikro-orm/postgresql"; +import { BaseRepository } from "../../../common/base/repository"; +import { ChatMessageModel } from "../models/chat-message.model"; +import { IChatMessage, MessageStatus } from "../models/Abstraction/IChatMessage"; -import { ChatMessage, MessageStatus } from "../entities/chat-message.entity"; - -export class ChatMessageRepository extends EntityRepository { - async findBySessionWithPagination(sessionId: string, offset = 0, limit = 50, em?: EntityManager) { - const repository = em ? em.getRepository(ChatMessage) : this; - return repository.find( - { session: sessionId }, - { - populate: ["sender"], - orderBy: { createdAt: "ASC" }, - offset, - limit, - }, - ); +class ChatMessageRepository extends BaseRepository { + constructor() { + super(ChatMessageModel); } - async getConversationHistory(sessionId: string, limit = 20, em?: EntityManager) { - const repository = em ? em.getRepository(ChatMessage) : this; - return repository.find( - { session: sessionId }, - { - orderBy: { createdAt: "DESC" }, - limit, - }, - ); + async findBySessionWithPagination(sessionId: string, offset = 0, limit = 50) { + return this.model + .find({ session: sessionId }) + .populate("sender") + .sort({ createdAt: 1 }) + .skip(offset) + .limit(limit) + .exec(); } - async markAsRead(messageIds: string[], em?: EntityManager) { - const repository = em ? em.getRepository(ChatMessage) : this; - return repository.nativeUpdate({ id: { $in: messageIds } }, { status: MessageStatus.READ }); + async getConversationHistory(sessionId: string, limit = 20) { + return this.model + .find({ session: sessionId }) + .sort({ createdAt: -1 }) + .limit(limit) + .exec(); + } + + async markAsRead(messageIds: string[]) { + return this.model.updateMany({ _id: { $in: messageIds } }, { status: MessageStatus.READ }); } } + +function createChatMessageRepository(): ChatMessageRepository { + return new ChatMessageRepository(); +} + +export { ChatMessageRepository, createChatMessageRepository }; diff --git a/src/modules/chatbot/repositories/chat-session.repository.ts b/src/modules/chatbot/repositories/chat-session.repository.ts index aa9081b..83fb807 100644 --- a/src/modules/chatbot/repositories/chat-session.repository.ts +++ b/src/modules/chatbot/repositories/chat-session.repository.ts @@ -1,33 +1,40 @@ -import { EntityManager, EntityRepository } from "@mikro-orm/postgresql"; +import { BaseRepository } from "../../../common/base/repository"; +import { ChatSessionModel, ChatSessionStatus } from "../models/chat-session.model"; +import { IChatSession } from "../models/Abstraction/IChatSession"; -import { ChatSession, ChatSessionStatus } from "../entities/chat-session.entity"; - -export class ChatSessionRepository extends EntityRepository { - async findByUserWithMessages(userId: string, limit = 10, em?: EntityManager) { - const repository = em ? em.getRepository(ChatSession) : this; - return repository.find( - { user: userId }, - { - populate: ["messages"], - orderBy: { lastMessageAt: "DESC" }, - limit, - }, - ); +class ChatSessionRepository extends BaseRepository { + constructor() { + super(ChatSessionModel); } - async findActiveByUser(userId: string, em?: EntityManager) { - const repository = em ? em.getRepository(ChatSession) : this; - return repository.find( - { user: userId, status: ChatSessionStatus.ACTIVE }, - { - populate: ["messages"], - orderBy: { lastMessageAt: "DESC" }, - }, - ); + async findByUserWithMessages(userId: string, limit = 10) { + return this.model + .find({ user: userId }) + .populate("messages") + .sort({ lastMessageAt: -1 }) + .limit(limit) + .exec(); } - async updateLastMessageTime(sessionId: string, em?: EntityManager) { - const repository = em ? em.getRepository(ChatSession) : this; - return repository.nativeUpdate({ id: sessionId }, { lastMessageAt: new Date() }); + async findActiveByUser(userId: string) { + return this.model + .find({ user: userId, status: ChatSessionStatus.ACTIVE }) + .populate("messages") + .sort({ lastMessageAt: -1 }) + .exec(); + } + + async updateLastMessageTime(sessionId: string) { + return this.model.findByIdAndUpdate(sessionId, { lastMessageAt: new Date() }, { new: true }); + } + + async findByUserAndId(sessionId: string, userId: string) { + return this.model.findOne({ _id: sessionId, user: userId }).populate("messages").populate("messages.sender").exec(); } } + +function createChatSessionRepository(): ChatSessionRepository { + return new ChatSessionRepository(); +} + +export { ChatSessionRepository, createChatSessionRepository };