chatbot : init
This commit is contained in:
@@ -0,0 +1,495 @@
|
||||
import { EntityManager } from "@mikro-orm/postgresql";
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
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 { 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 { IChatContext } from "../interfaces/chatbot.interface";
|
||||
import { ChatMessageRepository } from "../repositories/chat-message.repository";
|
||||
import { ChatSessionRepository } from "../repositories/chat-session.repository";
|
||||
|
||||
export enum LLMProvider {
|
||||
LANGCHAIN = "langchain",
|
||||
GEMINI = "gemini",
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ChatbotService {
|
||||
private readonly logger = new Logger(ChatbotService.name);
|
||||
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,
|
||||
) {
|
||||
// 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`);
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
const session = new ChatSession();
|
||||
session.title = createDto.title;
|
||||
session.user = user;
|
||||
session.context = createDto.context || {};
|
||||
session.lastMessageAt = new Date();
|
||||
|
||||
await em.persistAndFlush(session);
|
||||
|
||||
return this.mapSessionToDto(session);
|
||||
}
|
||||
//************************************ */
|
||||
async getUserChatSessions(userId: string, limit = 10) {
|
||||
const em = this.em.fork();
|
||||
const sessions = await this.chatSessionRepo.findByUserWithMessages(userId, limit, em);
|
||||
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 (!session) {
|
||||
throw new NotFoundException("Chat session not found");
|
||||
}
|
||||
|
||||
return this.mapSessionToDto(session);
|
||||
}
|
||||
//************************************ */
|
||||
|
||||
async sendMessage(userId: string, sendDto: SendMessageDto, provider?: LLMProvider) {
|
||||
const selectedProvider = provider || this.defaultProvider;
|
||||
|
||||
// Use LangChain by default for enhanced responses
|
||||
if (selectedProvider === LLMProvider.LANGCHAIN) {
|
||||
try {
|
||||
return await this.sendMessageWithLangChain(userId, sendDto);
|
||||
} catch (error) {
|
||||
this.logger.error("LangChain failed, falling back to Gemini", error);
|
||||
// Fallback to Gemini service
|
||||
return await this.sendMessageWithGemini(userId, sendDto);
|
||||
}
|
||||
} else {
|
||||
return await this.sendMessageWithGemini(userId, sendDto);
|
||||
}
|
||||
}
|
||||
|
||||
//************************************ */
|
||||
|
||||
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 (!session) {
|
||||
throw new NotFoundException("Chat session not found");
|
||||
}
|
||||
|
||||
if (session.status !== ChatSessionStatus.ACTIVE) {
|
||||
throw new BadRequestException("Cannot send message to inactive session");
|
||||
}
|
||||
|
||||
const user = await em.findOne(User, { id: userId });
|
||||
if (!user) {
|
||||
throw new NotFoundException("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);
|
||||
|
||||
// Update session last message time
|
||||
await this.chatSessionRepo.updateLastMessageTime(session.id, em);
|
||||
|
||||
// Generate bot response asynchronously using original Gemini service
|
||||
this.generateBotResponse(session, userMessage, userId);
|
||||
|
||||
return this.mapMessageToDto(userMessage);
|
||||
}
|
||||
|
||||
//************************************ */
|
||||
|
||||
async sendMessageStream(userId: string, sendDto: SendMessageDto, provider?: LLMProvider) {
|
||||
const selectedProvider = provider || this.defaultProvider;
|
||||
|
||||
// Use LangChain by default for enhanced streaming responses
|
||||
if (selectedProvider === LLMProvider.LANGCHAIN) {
|
||||
try {
|
||||
return await this.sendMessageStreamWithLangChain(userId, sendDto);
|
||||
} catch (error) {
|
||||
this.logger.error("LangChain streaming failed, falling back to Gemini", error);
|
||||
// Fallback to Gemini service
|
||||
return await this.sendMessageStreamWithGemini(userId, sendDto);
|
||||
}
|
||||
} else {
|
||||
return await this.sendMessageStreamWithGemini(userId, sendDto);
|
||||
}
|
||||
}
|
||||
|
||||
//************************************ */
|
||||
|
||||
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 (!session) {
|
||||
throw new NotFoundException("Chat session not found");
|
||||
}
|
||||
|
||||
if (session.status !== ChatSessionStatus.ACTIVE) {
|
||||
throw new BadRequestException("Cannot send message to inactive session");
|
||||
}
|
||||
|
||||
const user = await em.findOne(User, { id: userId });
|
||||
if (!user) {
|
||||
throw new NotFoundException("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);
|
||||
|
||||
// Update session last message time
|
||||
await this.chatSessionRepo.updateLastMessageTime(session.id, em);
|
||||
|
||||
// Get conversation history for context
|
||||
const history = await this.chatMessageRepo.getConversationHistory(session.id, 20, em);
|
||||
|
||||
// Build context for LLM
|
||||
const context: IChatContext = {
|
||||
userId,
|
||||
sessionId: session.id,
|
||||
conversationHistory: history.reverse().map((msg) => ({
|
||||
content: msg.content,
|
||||
type: msg.type as "user" | "bot" | "system",
|
||||
timestamp: msg.createdAt,
|
||||
metadata: msg.metadata,
|
||||
})),
|
||||
userPreferences: session.context,
|
||||
};
|
||||
|
||||
// Return a function that generates the stream using original LLM service
|
||||
const streamGenerator = () => this.llmService.generateStreamResponse(sendDto.content, context);
|
||||
|
||||
return {
|
||||
userMessage: this.mapMessageToDto(userMessage),
|
||||
streamGenerator,
|
||||
};
|
||||
}
|
||||
|
||||
//************************************ */
|
||||
|
||||
private async generateBotResponse(session: ChatSession, userMessage: ChatMessage, userId: string): Promise<void> {
|
||||
const em = this.em.fork();
|
||||
try {
|
||||
// Get conversation history
|
||||
const history = await this.chatMessageRepo.getConversationHistory(session.id, 20, em);
|
||||
|
||||
// Build context for LLM
|
||||
const context: IChatContext = {
|
||||
userId,
|
||||
sessionId: session.id,
|
||||
conversationHistory: history.reverse().map((msg) => ({
|
||||
content: msg.content,
|
||||
type: msg.type as "user" | "bot" | "system",
|
||||
timestamp: msg.createdAt,
|
||||
metadata: msg.metadata,
|
||||
})),
|
||||
userPreferences: session.context,
|
||||
};
|
||||
|
||||
// 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);
|
||||
|
||||
// Update session last message time
|
||||
await this.chatSessionRepo.updateLastMessageTime(session.id, em);
|
||||
|
||||
this.logger.log(`Generated bot response for session ${session.id}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to generate bot response for session ${session.id}`, 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);
|
||||
}
|
||||
}
|
||||
|
||||
//************************************ */
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
session.status = ChatSessionStatus.CLOSED;
|
||||
await em.persistAndFlush(session);
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
await this.chatMessageRepo.markAsRead(messageIds, em);
|
||||
|
||||
return { message: "Messages marked as read successfully" };
|
||||
}
|
||||
|
||||
//************************************ */
|
||||
|
||||
private mapSessionToDto(session: ChatSession) {
|
||||
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)) : [],
|
||||
};
|
||||
}
|
||||
|
||||
//************************************ */
|
||||
|
||||
private mapMessageToDto(message: ChatMessage) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
//************************************ */
|
||||
|
||||
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 (!session) {
|
||||
throw new NotFoundException("Chat session not found");
|
||||
}
|
||||
|
||||
if (session.status !== ChatSessionStatus.ACTIVE) {
|
||||
throw new BadRequestException("Cannot send message to inactive session");
|
||||
}
|
||||
|
||||
const user = await em.findOne(User, { id: userId });
|
||||
if (!user) {
|
||||
throw new NotFoundException("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);
|
||||
|
||||
// Update session last message time
|
||||
await this.chatSessionRepo.updateLastMessageTime(session.id, em);
|
||||
|
||||
// Generate bot response using LangChain
|
||||
this.generateBotResponseWithLangChain(session, userMessage, 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 (!session) {
|
||||
throw new NotFoundException("Chat session not found");
|
||||
}
|
||||
|
||||
if (session.status !== ChatSessionStatus.ACTIVE) {
|
||||
throw new BadRequestException("Cannot send message to inactive session");
|
||||
}
|
||||
|
||||
const user = await em.findOne(User, { id: userId });
|
||||
if (!user) {
|
||||
throw new NotFoundException("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);
|
||||
|
||||
// Update session last message time
|
||||
await this.chatSessionRepo.updateLastMessageTime(session.id, em);
|
||||
|
||||
// Get conversation history for context
|
||||
const history = await this.chatMessageRepo.getConversationHistory(session.id, 20, em);
|
||||
|
||||
// Build context for LangChain
|
||||
const context: IChatContext = {
|
||||
userId,
|
||||
sessionId: session.id,
|
||||
conversationHistory: history.reverse().map((msg) => ({
|
||||
content: msg.content,
|
||||
type: msg.type as "user" | "bot" | "system",
|
||||
timestamp: msg.createdAt,
|
||||
metadata: msg.metadata,
|
||||
})),
|
||||
userPreferences: session.context,
|
||||
};
|
||||
|
||||
// Return a function that generates the stream using LangChain
|
||||
const streamGenerator = () => this.langChainService.generateStreamResponse(sendDto.content, context);
|
||||
|
||||
return {
|
||||
userMessage: this.mapMessageToDto(userMessage),
|
||||
streamGenerator,
|
||||
};
|
||||
}
|
||||
|
||||
//************************************ */
|
||||
|
||||
private async generateBotResponseWithLangChain(session: ChatSession, userMessage: ChatMessage, userId: string): Promise<void> {
|
||||
const em = this.em.fork();
|
||||
try {
|
||||
// Get conversation history
|
||||
const history = await this.chatMessageRepo.getConversationHistory(session.id, 20, em);
|
||||
|
||||
// Build context for LangChain
|
||||
const context: IChatContext = {
|
||||
userId,
|
||||
sessionId: session.id,
|
||||
conversationHistory: history.reverse().map((msg) => ({
|
||||
content: msg.content,
|
||||
type: msg.type as "user" | "bot" | "system",
|
||||
timestamp: msg.createdAt,
|
||||
metadata: msg.metadata,
|
||||
})),
|
||||
userPreferences: session.context,
|
||||
};
|
||||
|
||||
// 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);
|
||||
|
||||
// Update session last message time
|
||||
await this.chatSessionRepo.updateLastMessageTime(session.id, em);
|
||||
|
||||
this.logger.log(`Generated LangChain bot response for session ${session.id}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to generate LangChain bot response for session ${session.id}`, 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);
|
||||
}
|
||||
}
|
||||
|
||||
//************************************ */
|
||||
|
||||
async refreshLangChainData() {
|
||||
try {
|
||||
await this.langChainService.refreshVectorStore();
|
||||
this.logger.log("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");
|
||||
}
|
||||
}
|
||||
|
||||
//************************************ */
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { EntityManager } from "@mikro-orm/core";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
|
||||
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";
|
||||
|
||||
@Injectable()
|
||||
export class DataContextService {
|
||||
private readonly logger = new Logger(DataContextService.name);
|
||||
|
||||
constructor(private em: EntityManager) {}
|
||||
|
||||
async getRelevantContext(message: string, context: IChatContext): Promise<{ data: string[]; sources: string[] }> {
|
||||
const relevantData: string[] = [];
|
||||
const sources: string[] = [];
|
||||
|
||||
try {
|
||||
// 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),
|
||||
]);
|
||||
|
||||
return { data: relevantData, sources };
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to get relevant context", error);
|
||||
return { data: [], sources: [] };
|
||||
}
|
||||
}
|
||||
|
||||
private extractKeywords(message: string): string[] {
|
||||
const commonWords = ["the", "is", "at", "which", "on", "a", "an", "and", "or", "but", "in", "with", "to", "for", "of", "as", "by"];
|
||||
|
||||
return message
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s]/g, "")
|
||||
.split(/\s+/)
|
||||
.filter((word) => word.length > 2 && !commonWords.includes(word));
|
||||
}
|
||||
|
||||
private async getBusinessData(keywords: string[], userId: string, relevantData: string[], sources: string[]): Promise<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getInvoiceData(keywords: string[], userId: string, relevantData: string[], sources: string[]): Promise<void> {
|
||||
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 async getPaymentData(keywords: string[], userId: string, relevantData: string[], sources: string[]): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
import { Document } from "@langchain/core/documents";
|
||||
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 { 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";
|
||||
|
||||
@Injectable()
|
||||
export class LangChainService implements OnModuleInit {
|
||||
private readonly logger = new Logger(LangChainService.name);
|
||||
private embeddings: GoogleGenerativeAIEmbeddings;
|
||||
private llm: ChatGoogleGenerativeAI;
|
||||
private vectorStore: MemoryVectorStore;
|
||||
private textSplitter: RecursiveCharacterTextSplitter;
|
||||
private isInitialized = false;
|
||||
|
||||
constructor(
|
||||
private em: EntityManager,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
// Initialize Google Gemini components
|
||||
this.embeddings = new GoogleGenerativeAIEmbeddings({
|
||||
apiKey: this.configService.getOrThrow("GEMINI_API_KEY"),
|
||||
modelName: "embedding-001", // Google's embedding model
|
||||
});
|
||||
|
||||
this.llm = new ChatGoogleGenerativeAI({
|
||||
apiKey: this.configService.getOrThrow("GEMINI_API_KEY"),
|
||||
model: CHATBOT_CONSTANTS.DEFAULT_MODEL,
|
||||
temperature: CHATBOT_CONSTANTS.DEFAULT_TEMPERATURE,
|
||||
maxOutputTokens: CHATBOT_CONSTANTS.DEFAULT_MAX_TOKENS,
|
||||
});
|
||||
|
||||
this.textSplitter = new RecursiveCharacterTextSplitter({
|
||||
chunkSize: 1000,
|
||||
chunkOverlap: 200,
|
||||
separators: ["\n\n", "\n", ".", "!", "?", "؟", "!", ".", " ", ""],
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.initializeVectorStore();
|
||||
}
|
||||
|
||||
private async initializeVectorStore() {
|
||||
try {
|
||||
this.logger.log(CHATBOT_CONSTANTS.LANGCHAIN_MESSAGES.LOADING_DATA);
|
||||
|
||||
// Load company and industry data for training
|
||||
const documents = await this.loadCompanyAndIndustryDocuments();
|
||||
|
||||
if (documents.length === 0) {
|
||||
this.logger.warn(CHATBOT_CONSTANTS.LANGCHAIN_MESSAGES.NO_DATA_FOUND);
|
||||
this.vectorStore = new MemoryVectorStore(this.embeddings);
|
||||
this.isInitialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Split documents into chunks
|
||||
const splitDocs = await this.textSplitter.splitDocuments(documents);
|
||||
|
||||
this.logger.log(
|
||||
CHATBOT_CONSTANTS.LANGCHAIN_MESSAGES.DOCUMENTS_SPLIT.replace("{totalDocs}", documents.length.toString()).replace(
|
||||
"{chunks}",
|
||||
splitDocs.length.toString(),
|
||||
),
|
||||
);
|
||||
|
||||
// Create vector store from documents
|
||||
this.vectorStore = await MemoryVectorStore.fromDocuments(splitDocs, this.embeddings);
|
||||
|
||||
this.isInitialized = true;
|
||||
this.logger.log(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
|
||||
this.vectorStore = new MemoryVectorStore(this.embeddings);
|
||||
this.isInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadCompanyAndIndustryDocuments(): Promise<Document[]> {
|
||||
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,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// Add comprehensive Farsi guidance documents from constants
|
||||
const companyGuidanceDocuments = [
|
||||
new Document({
|
||||
pageContent: CHATBOT_CONSTANTS.COMPANY_GUIDANCE_DOCUMENTS.COMPANY_GUIDE,
|
||||
metadata: {
|
||||
type: "company_guide",
|
||||
category: "guidance",
|
||||
language: "فارسی",
|
||||
},
|
||||
}),
|
||||
new Document({
|
||||
pageContent: CHATBOT_CONSTANTS.COMPANY_GUIDANCE_DOCUMENTS.COMPANY_FAQ,
|
||||
metadata: {
|
||||
type: "company_faq",
|
||||
category: "support",
|
||||
language: "فارسی",
|
||||
},
|
||||
}),
|
||||
new Document({
|
||||
pageContent: CHATBOT_CONSTANTS.COMPANY_GUIDANCE_DOCUMENTS.SEARCH_GUIDE,
|
||||
metadata: {
|
||||
type: "search_guide",
|
||||
category: "tutorial",
|
||||
language: "فارسی",
|
||||
},
|
||||
}),
|
||||
new Document({
|
||||
pageContent: CHATBOT_CONSTANTS.COMPANY_GUIDANCE_DOCUMENTS.INDUSTRY_GUIDE,
|
||||
metadata: {
|
||||
type: "industry_guide",
|
||||
category: "education",
|
||||
language: "فارسی",
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
documents.push(...companyGuidanceDocuments);
|
||||
|
||||
this.logger.log(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);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async generateResponse(message: string, context: IChatContext): Promise<IChatbotResponse> {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error(CHATBOT_CONSTANTS.LANGCHAIN_MESSAGES.SERVICE_NOT_INITIALIZED);
|
||||
}
|
||||
|
||||
try {
|
||||
// Retrieve relevant documents based on the user's query
|
||||
const relevantDocs = await this.vectorStore.similaritySearch(message, 5);
|
||||
|
||||
// Build context from retrieved documents
|
||||
const retrievedContext = relevantDocs.map((doc, index) => `${index + 1}. ${doc.pageContent}`).join("\n\n");
|
||||
|
||||
// Build conversation history
|
||||
const conversationHistory =
|
||||
context.conversationHistory
|
||||
?.slice(-CHATBOT_CONSTANTS.MAX_CONVERSATION_HISTORY / 4)
|
||||
?.map((msg) => {
|
||||
const roleLabel = msg.type === "user" ? "کاربر" : msg.type === "bot" ? "ربات" : "سیستم";
|
||||
return `${roleLabel}: ${msg.content}`;
|
||||
})
|
||||
?.join("\n") || "";
|
||||
|
||||
// Create the prompt template using constants
|
||||
const promptTemplate = PromptTemplate.fromTemplate(CHATBOT_CONSTANTS.COMPANY_GUIDANCE_SYSTEM_PROMPT);
|
||||
|
||||
// Create the runnable sequence
|
||||
const chain = RunnableSequence.from([promptTemplate, this.llm, new StringOutputParser()]);
|
||||
|
||||
// Execute the chain
|
||||
const response = await chain.invoke({
|
||||
context: retrievedContext,
|
||||
conversation_history: conversationHistory,
|
||||
question: message,
|
||||
});
|
||||
|
||||
// Calculate approximate token usage
|
||||
const tokensUsed = this.estimateTokens(retrievedContext) + this.estimateTokens(message) + this.estimateTokens(response);
|
||||
|
||||
return {
|
||||
message: response,
|
||||
confidence: 0.9, // Higher confidence for company guidance
|
||||
sources: relevantDocs.map((doc) => {
|
||||
const type = doc.metadata.type || "شرکت";
|
||||
return CHATBOT_CONSTANTS.SOURCE_TYPE_LABELS[type as keyof typeof CHATBOT_CONSTANTS.SOURCE_TYPE_LABELS] || type;
|
||||
}),
|
||||
tokensUsed,
|
||||
context: {
|
||||
model: `${CHATBOT_CONSTANTS.DEFAULT_MODEL}-company-guide`,
|
||||
relevantDataFound: relevantDocs.length > 0,
|
||||
documentsRetrieved: relevantDocs.length,
|
||||
vectorStoreInitialized: this.isInitialized,
|
||||
language: "فارسی",
|
||||
dataTypes: relevantDocs.map((doc) => doc.metadata.type).filter((type, index, arr) => arr.indexOf(type) === index),
|
||||
focus: "company_and_industry_guidance",
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(CHATBOT_CONSTANTS.LANGCHAIN_MESSAGES.RESPONSE_ERROR, error);
|
||||
throw new Error(CHATBOT_CONSTANTS.ERROR_MESSAGES.LLM_SERVICE_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
async generateStreamResponse(message: string, context: IChatContext) {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error(CHATBOT_CONSTANTS.LANGCHAIN_MESSAGES.SERVICE_NOT_INITIALIZED);
|
||||
}
|
||||
|
||||
try {
|
||||
// Retrieve relevant documents
|
||||
const relevantDocs = await this.vectorStore.similaritySearch(message, 5);
|
||||
const retrievedContext = relevantDocs.map((doc, index) => `${index + 1}. ${doc.pageContent}`).join("\n\n");
|
||||
|
||||
// Build conversation history
|
||||
const conversationHistory =
|
||||
context.conversationHistory
|
||||
?.slice(-CHATBOT_CONSTANTS.MAX_CONVERSATION_HISTORY / 4)
|
||||
?.map((msg) => {
|
||||
const roleLabel = msg.type === "user" ? "کاربر" : msg.type === "bot" ? "ربات" : "سیستم";
|
||||
return `${roleLabel}: ${msg.content}`;
|
||||
})
|
||||
?.join("\n") || "";
|
||||
|
||||
// Create prompt template using constants
|
||||
const promptTemplate = PromptTemplate.fromTemplate(CHATBOT_CONSTANTS.COMPANY_GUIDANCE_SYSTEM_PROMPT);
|
||||
|
||||
// Create streaming chain
|
||||
const chain = RunnableSequence.from([promptTemplate, this.llm, new StringOutputParser()]);
|
||||
|
||||
// Return async iterable for streaming
|
||||
const stream = await chain.stream({
|
||||
context: retrievedContext,
|
||||
conversation_history: conversationHistory,
|
||||
question: message,
|
||||
});
|
||||
|
||||
return stream;
|
||||
} catch (error) {
|
||||
this.logger.error(CHATBOT_CONSTANTS.LANGCHAIN_MESSAGES.STREAM_RESPONSE_ERROR, error);
|
||||
throw new Error(CHATBOT_CONSTANTS.ERROR_MESSAGES.LLM_SERVICE_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshVectorStore(): Promise<void> {
|
||||
this.logger.log(CHATBOT_CONSTANTS.LANGCHAIN_MESSAGES.REFRESHING_VECTOR_STORE);
|
||||
await this.initializeVectorStore();
|
||||
}
|
||||
|
||||
private estimateTokens(text: string): number {
|
||||
// Rough estimation: 1 token ≈ 4 characters for most languages
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace placeholders in template with actual values
|
||||
*/
|
||||
private replaceTemplate(template: string, values: Record<string, string>): 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { GoogleGenerativeAI, HarmBlockThreshold, HarmCategory } from "@google/generative-ai";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
|
||||
import { DataContextService } from "./data-context.service";
|
||||
import { CHATBOT_CONSTANTS } from "../constants/chatbot.constants";
|
||||
import { IChatContext, IChatbotResponse, ILLMConfig } from "../interfaces/chatbot.interface";
|
||||
|
||||
@Injectable()
|
||||
export class LLMService {
|
||||
private readonly logger = new Logger(LLMService.name);
|
||||
private genAI: GoogleGenerativeAI;
|
||||
private config: ILLMConfig;
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private dataContextService: DataContextService,
|
||||
) {
|
||||
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"),
|
||||
};
|
||||
|
||||
this.genAI = new GoogleGenerativeAI(this.config.apiKey);
|
||||
}
|
||||
|
||||
async generateResponse(message: string, context: IChatContext): Promise<IChatbotResponse> {
|
||||
try {
|
||||
// Get relevant data from database
|
||||
const relevantData = await this.dataContextService.getRelevantContext(message, context);
|
||||
|
||||
// Build system instruction with context
|
||||
const systemInstruction = this.buildSystemInstruction(relevantData);
|
||||
|
||||
// Get the generative model with system instruction
|
||||
const model = this.genAI.getGenerativeModel({
|
||||
model: this.config.model,
|
||||
systemInstruction,
|
||||
generationConfig: {
|
||||
temperature: this.config.temperature,
|
||||
maxOutputTokens: this.config.maxTokens,
|
||||
topP: this.config.topP,
|
||||
},
|
||||
safetySettings: [
|
||||
{
|
||||
category: HarmCategory.HARM_CATEGORY_HARASSMENT,
|
||||
threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE,
|
||||
},
|
||||
{
|
||||
category: HarmCategory.HARM_CATEGORY_HATE_SPEECH,
|
||||
threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE,
|
||||
},
|
||||
{
|
||||
category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
|
||||
threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE,
|
||||
},
|
||||
{
|
||||
category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
|
||||
threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Build conversation history
|
||||
const conversationHistory = this.buildConversationHistory(context);
|
||||
|
||||
// Generate response using conversation history or direct message
|
||||
let result;
|
||||
if (conversationHistory.length > 0) {
|
||||
// Use chat session for multi-turn conversation
|
||||
const chat = model.startChat({
|
||||
history: conversationHistory,
|
||||
});
|
||||
result = await chat.sendMessage(message);
|
||||
} else {
|
||||
// Single message generation
|
||||
result = await model.generateContent(message);
|
||||
}
|
||||
|
||||
const response = await result.response;
|
||||
const botMessage = response.text() || "متأسفم، نمیتوانم پاسخی تولید کنم. لطفاً سوال خود را دوباره مطرح کنید. 🤖";
|
||||
|
||||
// Calculate token usage (approximate)
|
||||
const tokensUsed = this.estimateTokens(systemInstruction) + this.estimateTokens(message) + this.estimateTokens(botMessage);
|
||||
|
||||
return {
|
||||
message: botMessage,
|
||||
confidence: this.calculateConfidence(response),
|
||||
sources: relevantData.sources,
|
||||
tokensUsed,
|
||||
context: {
|
||||
model: this.config.model,
|
||||
relevantDataFound: relevantData.data.length > 0,
|
||||
finishReason: response.candidates?.[0]?.finishReason || "unknown",
|
||||
safetyRatings: response.candidates?.[0]?.safetyRatings || [],
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to generate Gemini response", error);
|
||||
throw new Error("Failed to generate response from AI service");
|
||||
}
|
||||
}
|
||||
|
||||
async generateStreamResponse(message: string, context: IChatContext): Promise<AsyncIterable<string>> {
|
||||
try {
|
||||
// Get relevant data from database
|
||||
const relevantData = await this.dataContextService.getRelevantContext(message, context);
|
||||
|
||||
// Build system instruction with context
|
||||
const systemInstruction = this.buildSystemInstruction(relevantData);
|
||||
|
||||
// Get the generative model
|
||||
const model = this.genAI.getGenerativeModel({
|
||||
model: this.config.model,
|
||||
systemInstruction,
|
||||
generationConfig: {
|
||||
temperature: this.config.temperature,
|
||||
maxOutputTokens: this.config.maxTokens,
|
||||
topP: this.config.topP,
|
||||
},
|
||||
});
|
||||
|
||||
// Build conversation history
|
||||
const conversationHistory = this.buildConversationHistory(context);
|
||||
|
||||
let streamResult;
|
||||
if (conversationHistory.length > 0) {
|
||||
const chat = model.startChat({
|
||||
history: conversationHistory,
|
||||
});
|
||||
streamResult = await chat.sendMessageStream(message);
|
||||
} else {
|
||||
streamResult = await model.generateContentStream(message);
|
||||
}
|
||||
|
||||
return this.createAsyncIterableFromStream(streamResult);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to generate streaming Gemini response", error);
|
||||
throw new Error("Failed to generate streaming response from AI service");
|
||||
}
|
||||
}
|
||||
|
||||
private buildSystemInstruction(relevantData: { data: string[]; sources: string[] }): string {
|
||||
let instruction = CHATBOT_CONSTANTS.SYSTEM_PROMPT;
|
||||
|
||||
// Add relevant data context
|
||||
if (relevantData.data.length > 0) {
|
||||
instruction += "\n\nRelevant information from our database:\n";
|
||||
instruction += relevantData.data.map((data, index) => `${index + 1}. ${data}`).join("\n");
|
||||
instruction += "\n\nUse this information to provide accurate and specific answers.";
|
||||
}
|
||||
|
||||
return instruction;
|
||||
}
|
||||
|
||||
private buildConversationHistory(context: IChatContext): Array<{ role: string; parts: Array<{ text: string }> }> {
|
||||
if (!context.conversationHistory || context.conversationHistory.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const history = [];
|
||||
const recentHistory = context.conversationHistory.slice(-CHATBOT_CONSTANTS.MAX_CONVERSATION_HISTORY).filter((msg) => msg.type !== "system");
|
||||
|
||||
for (const msg of recentHistory) {
|
||||
history.push({
|
||||
role: msg.type === "user" ? "user" : "model",
|
||||
parts: [{ text: msg.content }],
|
||||
});
|
||||
}
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
private async *createAsyncIterableFromStream(streamResult: any): AsyncIterable<string> {
|
||||
try {
|
||||
// For Google Generative AI, the streamResult itself is the async iterable
|
||||
for await (const chunk of streamResult.stream) {
|
||||
const chunkText = chunk.text();
|
||||
if (chunkText) {
|
||||
yield chunkText;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error("Error in streaming response", error);
|
||||
|
||||
// If the above doesn't work, try alternative approach for older SDK versions
|
||||
try {
|
||||
const response = await streamResult.response;
|
||||
const text = response.text();
|
||||
if (text) {
|
||||
yield text;
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
this.logger.error("Fallback streaming approach also failed", fallbackError);
|
||||
throw new Error("Failed to process streaming response");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private calculateConfidence(response: any): number {
|
||||
const text = response.text() || "";
|
||||
|
||||
// Check safety ratings - lower confidence if blocked
|
||||
const safetyRatings = response.candidates?.[0]?.safetyRatings || [];
|
||||
const hasHighRiskRatings = safetyRatings.some((rating: any) => rating.probability === "HIGH" || rating.probability === "MEDIUM");
|
||||
|
||||
if (hasHighRiskRatings) {
|
||||
return 0.4;
|
||||
}
|
||||
|
||||
// Check for uncertainty indicators
|
||||
if (text.includes("I don't know") || text.includes("I'm not sure") || text.includes("uncertain")) {
|
||||
return 0.3;
|
||||
}
|
||||
|
||||
// Check response length and quality
|
||||
if (text.length < 50) {
|
||||
return 0.6;
|
||||
}
|
||||
|
||||
// Check if response uses provided data
|
||||
if (text.includes("based on") || text.includes("according to")) {
|
||||
return 0.95;
|
||||
}
|
||||
|
||||
return 0.8;
|
||||
}
|
||||
|
||||
private estimateTokens(text: string): number {
|
||||
// Rough estimation: 1 token ≈ 4 characters for English text
|
||||
// Gemini uses similar tokenization to other models
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
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 { WEBSOCKET_EVENTS } from "../constants/chatbot.constants";
|
||||
import { AuthenticationResult, WebSocketErrorContext } from "../interfaces/websocket.interface";
|
||||
|
||||
/**
|
||||
* Service responsible for WebSocket authentication logic
|
||||
* Follows single responsibility principle and provides reusable authentication methods
|
||||
*/
|
||||
@Injectable()
|
||||
export class WebSocketAuthService {
|
||||
private readonly logger = new Logger(WebSocketAuthService.name);
|
||||
|
||||
constructor(private readonly jwtService: JwtService) {}
|
||||
|
||||
/**
|
||||
* Authenticates a WebSocket client and returns the result
|
||||
*
|
||||
* @param client - Socket.IO client to authenticate
|
||||
* @returns Promise with authentication result
|
||||
*/
|
||||
async authenticateClient(client: Socket): Promise<AuthenticationResult> {
|
||||
try {
|
||||
const token = extractTokenFromClient(client);
|
||||
|
||||
if (!token) {
|
||||
this.logAuthenticationAttempt(client, false, "No token provided");
|
||||
return {
|
||||
success: false,
|
||||
error: WebSocketMessage.AUTHENTICATION_REQUIRED,
|
||||
};
|
||||
}
|
||||
|
||||
const payload = await this.jwtService.verifyAsync<ITokenPayload>(token);
|
||||
|
||||
if (!payload?.id) {
|
||||
this.logAuthenticationAttempt(client, false, "Invalid token payload");
|
||||
return {
|
||||
success: false,
|
||||
error: WebSocketMessage.INVALID_TOKEN,
|
||||
};
|
||||
}
|
||||
|
||||
// Store user data in client
|
||||
client.data.user = payload;
|
||||
|
||||
this.logAuthenticationAttempt(client, true, undefined, payload.id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: payload,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = this.getAuthErrorMessage(error);
|
||||
this.logAuthenticationAttempt(client, false, errorMessage);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits authentication failure event to client and disconnects
|
||||
*
|
||||
* @param client - Socket.IO client
|
||||
* @param error - Error message to send
|
||||
*/
|
||||
handleAuthenticationFailure(client: Socket, error: string): void {
|
||||
client.emit(WEBSOCKET_EVENTS.UNAUTHORIZED, {
|
||||
message: error,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Graceful disconnect with delay to ensure message is received
|
||||
setTimeout(() => {
|
||||
client.disconnect(true);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits successful authentication event to client
|
||||
*
|
||||
* @param client - Socket.IO client
|
||||
* @param user - Authenticated user payload
|
||||
*/
|
||||
emitAuthenticationSuccess(client: Socket, user: ITokenPayload): void {
|
||||
client.emit(WEBSOCKET_EVENTS.AUTHENTICATED, {
|
||||
message: WebSocketMessage.AUTHENTICATED,
|
||||
userId: user.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates error context object for logging and monitoring
|
||||
*
|
||||
* @param client - Socket.IO client
|
||||
* @param event - Event name (optional)
|
||||
* @param data - Event data (optional)
|
||||
* @returns WebSocket error context
|
||||
*/
|
||||
createErrorContext(client: Socket, event?: WEBSOCKET_EVENTS, data?: any): WebSocketErrorContext {
|
||||
return {
|
||||
clientId: client.id,
|
||||
userId: client.data?.user?.id,
|
||||
sessionId: client.data?.sessionId,
|
||||
event,
|
||||
data,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if client is authenticated
|
||||
*
|
||||
* @param client - Socket.IO client to validate
|
||||
* @returns True if client has valid user data
|
||||
*/
|
||||
isClientAuthenticated(client: Socket): boolean {
|
||||
return !!client.data?.user?.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets user ID from authenticated client
|
||||
*
|
||||
* @param client - Socket.IO client
|
||||
* @returns User ID or undefined if not authenticated
|
||||
*/
|
||||
getUserId(client: Socket): string | undefined {
|
||||
return client.data?.user?.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs authentication attempts with structured data
|
||||
*/
|
||||
private logAuthenticationAttempt(client: Socket, success: boolean, error?: string, userId?: string): void {
|
||||
const logData = {
|
||||
clientId: client.id,
|
||||
clientIP: client.handshake.address,
|
||||
success,
|
||||
userId,
|
||||
error,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (success) {
|
||||
this.logger.log(`WebSocket authentication successful`, logData);
|
||||
} else {
|
||||
this.logger.warn(`WebSocket authentication failed`, logData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps JWT errors to user-friendly messages
|
||||
*/
|
||||
private getAuthErrorMessage(error: any): string {
|
||||
if (error?.name === "TokenExpiredError") {
|
||||
return WebSocketMessage.TOKEN_EXPIRED;
|
||||
}
|
||||
|
||||
if (error?.name === "JsonWebTokenError") {
|
||||
return WebSocketMessage.INVALID_TOKEN;
|
||||
}
|
||||
|
||||
if (error?.name === "NotBeforeError") {
|
||||
return WebSocketMessage.INVALID_TOKEN;
|
||||
}
|
||||
|
||||
return WebSocketMessage.INVALID_TOKEN;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user