|
|
|
@@ -1,5 +1,5 @@
|
|
|
|
|
import { GoogleGenerativeAI, HarmBlockThreshold, HarmCategory } from "@google/generative-ai";
|
|
|
|
|
import { inject, injectable } from "inversify";
|
|
|
|
|
import OpenAI from "openai";
|
|
|
|
|
|
|
|
|
|
import { DataContextService } from "./data-context.service";
|
|
|
|
|
import { Logger } from "../../../core/logging/logger";
|
|
|
|
@@ -10,24 +10,26 @@ import { IChatContext, IChatbotResponse, ILLMConfig } from "../interfaces/chatbo
|
|
|
|
|
@injectable()
|
|
|
|
|
export class LLMService {
|
|
|
|
|
private readonly logger: Logger;
|
|
|
|
|
private genAI: GoogleGenerativeAI;
|
|
|
|
|
private openai: OpenAI;
|
|
|
|
|
private config: ILLMConfig;
|
|
|
|
|
|
|
|
|
|
constructor(@inject(IOCTYPES.ChatbotDataContextService) private dataContextService: DataContextService) {
|
|
|
|
|
this.logger = new Logger("LLMService");
|
|
|
|
|
this.config = {
|
|
|
|
|
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 || "",
|
|
|
|
|
model: process.env.OPENAI_BASE_URL || CHATBOT_CONSTANTS.DEFAULT_MODEL,
|
|
|
|
|
temperature: Number(process.env.OPENAI_TEMPERATURE || CHATBOT_CONSTANTS.DEFAULT_TEMPERATURE),
|
|
|
|
|
maxTokens: Number(process.env.OPENAI_MAX_TOKENS || CHATBOT_CONSTANTS.DEFAULT_MAX_TOKENS),
|
|
|
|
|
topP: Number(process.env.OPENAI_TOP_P || "0.95"),
|
|
|
|
|
apiKey: process.env.OPENAI_API_KEY || "",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (!this.config.apiKey) {
|
|
|
|
|
throw new Error("GEMINI_API_KEY is required");
|
|
|
|
|
throw new Error("OPENAI_API_KEY is required");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.genAI = new GoogleGenerativeAI(this.config.apiKey);
|
|
|
|
|
this.openai = new OpenAI({
|
|
|
|
|
apiKey: this.config.apiKey,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async generateResponse(message: string, context: IChatContext): Promise<IChatbotResponse> {
|
|
|
|
@@ -38,71 +40,52 @@ export class LLMService {
|
|
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
// Build messages array for OpenAI
|
|
|
|
|
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
|
|
|
|
|
{
|
|
|
|
|
role: "system",
|
|
|
|
|
content: systemInstruction,
|
|
|
|
|
},
|
|
|
|
|
...conversationHistory,
|
|
|
|
|
{
|
|
|
|
|
role: "user",
|
|
|
|
|
content: message,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const response = await result.response;
|
|
|
|
|
const botMessage = response.text() || "متأسفم، نمیتوانم پاسخی تولید کنم. لطفاً سوال خود را دوباره مطرح کنید. 🤖";
|
|
|
|
|
// Generate response using OpenAI
|
|
|
|
|
const completion = await this.openai.chat.completions.create({
|
|
|
|
|
model: this.config.model,
|
|
|
|
|
messages,
|
|
|
|
|
temperature: this.config.temperature,
|
|
|
|
|
max_tokens: this.config.maxTokens,
|
|
|
|
|
top_p: this.config.topP,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Calculate token usage (approximate)
|
|
|
|
|
const tokensUsed = this.estimateTokens(systemInstruction) + this.estimateTokens(message) + this.estimateTokens(botMessage);
|
|
|
|
|
const botMessage =
|
|
|
|
|
completion.choices[0]?.message?.content ||
|
|
|
|
|
"متأسفم، نمیتوانم پاسخی تولید کنم. لطفاً سوال خود را دوباره مطرح کنید. 🤖";
|
|
|
|
|
|
|
|
|
|
// Calculate token usage from OpenAI response
|
|
|
|
|
const tokensUsed = (completion.usage?.total_tokens || 0) + this.estimateTokens(systemInstruction);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
message: botMessage,
|
|
|
|
|
confidence: this.calculateConfidence(response),
|
|
|
|
|
confidence: this.calculateConfidence(completion),
|
|
|
|
|
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 || [],
|
|
|
|
|
finishReason: completion.choices[0]?.finish_reason || "unknown",
|
|
|
|
|
usage: completion.usage,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.logger.error("Failed to generate Gemini response", error);
|
|
|
|
|
this.logger.error("Failed to generate OpenAI response", error);
|
|
|
|
|
throw new Error("Failed to generate response from AI service");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@@ -115,33 +98,35 @@ export class LLMService {
|
|
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
// Build messages array for OpenAI
|
|
|
|
|
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
|
|
|
|
|
{
|
|
|
|
|
role: "system",
|
|
|
|
|
content: systemInstruction,
|
|
|
|
|
},
|
|
|
|
|
...conversationHistory,
|
|
|
|
|
{
|
|
|
|
|
role: "user",
|
|
|
|
|
content: message,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
return this.createAsyncIterableFromStream(streamResult);
|
|
|
|
|
// Generate streaming response using OpenAI
|
|
|
|
|
const stream = await this.openai.chat.completions.create({
|
|
|
|
|
model: this.config.model,
|
|
|
|
|
messages,
|
|
|
|
|
temperature: this.config.temperature,
|
|
|
|
|
max_tokens: this.config.maxTokens,
|
|
|
|
|
top_p: this.config.topP,
|
|
|
|
|
stream: true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return this.createAsyncIterableFromStream(stream);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.logger.error("Failed to generate streaming Gemini response", error);
|
|
|
|
|
this.logger.error("Failed to generate streaming OpenAI response", error);
|
|
|
|
|
throw new Error("Failed to generate streaming response from AI service");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@@ -159,65 +144,57 @@ export class LLMService {
|
|
|
|
|
return instruction;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private buildConversationHistory(context: IChatContext): Array<{ role: string; parts: Array<{ text: string }> }> {
|
|
|
|
|
private buildConversationHistory(context: IChatContext): OpenAI.Chat.Completions.ChatCompletionMessageParam[] {
|
|
|
|
|
if (!context.conversationHistory || context.conversationHistory.length === 0) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const history = [];
|
|
|
|
|
const history: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [];
|
|
|
|
|
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 }],
|
|
|
|
|
role: msg.type === "user" ? "user" : "assistant",
|
|
|
|
|
content: msg.content,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return history;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async *createAsyncIterableFromStream(streamResult: any): AsyncIterable<string> {
|
|
|
|
|
private async *createAsyncIterableFromStream(stream: AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>): 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;
|
|
|
|
|
for await (const chunk of stream) {
|
|
|
|
|
const content = chunk.choices[0]?.delta?.content;
|
|
|
|
|
if (content) {
|
|
|
|
|
yield content;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} 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");
|
|
|
|
|
}
|
|
|
|
|
throw new Error("Failed to process streaming response");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private calculateConfidence(response: any): number {
|
|
|
|
|
const text = response.text() || "";
|
|
|
|
|
private calculateConfidence(completion: OpenAI.Chat.Completions.ChatCompletion): number {
|
|
|
|
|
const text = completion.choices[0]?.message?.content || "";
|
|
|
|
|
|
|
|
|
|
// 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) {
|
|
|
|
|
// Check finish reason - lower confidence if stopped early
|
|
|
|
|
const finishReason = completion.choices[0]?.finish_reason;
|
|
|
|
|
if (finishReason === "length" || finishReason === "content_filter") {
|
|
|
|
|
return 0.4;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check for uncertainty indicators
|
|
|
|
|
if (text.includes("I don't know") || text.includes("I'm not sure") || text.includes("uncertain")) {
|
|
|
|
|
if (
|
|
|
|
|
text.includes("I don't know") ||
|
|
|
|
|
text.includes("I'm not sure") ||
|
|
|
|
|
text.includes("uncertain") ||
|
|
|
|
|
text.includes("نمیدانم") ||
|
|
|
|
|
text.includes("مطمئن نیستم")
|
|
|
|
|
) {
|
|
|
|
|
return 0.3;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -227,7 +204,7 @@ export class LLMService {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if response uses provided data
|
|
|
|
|
if (text.includes("based on") || text.includes("according to")) {
|
|
|
|
|
if (text.includes("based on") || text.includes("according to") || text.includes("بر اساس") || text.includes("طبق")) {
|
|
|
|
|
return 0.95;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -236,7 +213,7 @@ export class LLMService {
|
|
|
|
|
|
|
|
|
|
private estimateTokens(text: string): number {
|
|
|
|
|
// Rough estimation: 1 token ≈ 4 characters for English text
|
|
|
|
|
// Gemini uses similar tokenization to other models
|
|
|
|
|
// OpenAI uses similar tokenization to other models
|
|
|
|
|
return Math.ceil(text.length / 4);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|