import { env } from "@config/env";
import { Chat } from "@models/Chat.model";
import { Investigation } from "@models/investigation/investigation.model";
import { AssistantService } from "@services/assistant.service";
import { AuthGrpcService } from "@services/auth.service";
import { ContradictionProcessingService } from "@services/contradictionProcessing.service";
import { InvestigationBuilder } from "@services/investigation.builder";
import { InvestigationService } from "@services/investigation.service";
import { Intents } from "@typez/intent/enums";
import { toInvestigationResponseDto } from "@typez/investigation";
import { getLogger } from "@utils/asyncLocalStorage";
import { enrichInvestigationResponse } from "@utils/investigation-response.utils";
import { stripMarkdown } from "@utils/markdown.utils";
import { fetchUserProfile } from "@utils/user-profile.utils";
import { ObjectId } from "mongodb";
import { Types } from "mongoose";
import { convertInvestigationFromModel } from "../helpers/investigation";
/**
* Chat Service
* @category Services
*/
export class ChatService {
/**
* Investigation ID
* @category Services
*/
investigationId;
/**
* User ID
* @category Services
*/
userId;
/**
* Chat
* @category Services
*/
chat;
/**
* AI Assistant Service
* @category Services
*/
aiAssistantService;
/**
* Investigation Service
* @category Services
*/
investigationService;
/**
* Investigation Builder
* @category Services
*/
investigationBuilder;
/**
* gRPC Client
* @category Services
*/
grpcClient;
/**
* Constructor
* @category Services
*/
/**
* Constructor
* @category Services
* @param {ObjectId | null} investigationId - The investigation ID.
* @param {ObjectId | null} userId - The user ID.
*/
constructor(investigationId, userId) {
this.investigationId = investigationId;
this.userId = userId;
this.aiAssistantService = new AssistantService();
this.investigationService = new InvestigationService();
this.investigationBuilder = new InvestigationBuilder();
// Instantiate gRPC client once for reuse
this.grpcClient = new AuthGrpcService();
}
/**
* Starts a conversation with the AI Assistant.
* @category Services
* @returns {Promise<object | boolean>} The chat history if it exists; otherwise, a greeting message.
* @throws {Error} If something goes wrong when requesting the LLM.
*/
async startConversation() {
const logger = getLogger();
try {
if (!this.investigationId) {
logger.info("Started new chat conversation.");
const greetingMessage = env.ASSISTANT_GREETING_MESSAGE;
this.chat = new Chat({ history: [{ user: {}, assistant: { content: greetingMessage } }] });
await this.chat.save();
return {
date: this.chat?.history[0]?.assistant.createdAt,
message: greetingMessage,
error: false,
};
}
else {
logger.info(`Returned chat conversation for ${String(this.investigationId)} investigation.`);
this.chat = await Chat.findOne({ investigationId: this.investigationId });
if (!this.chat) {
this.chat = new Chat({ history: [], investigationId: this.investigationId });
await this.chat.save();
}
// Memoization cache to prevent duplicate Redis/gRPC calls for same userId in this request
// Can store either the profile data or a Promise (to handle parallel requests for the same userId)
const profileCache = new Map();
// Map history items with profile enrichment
// Filter out hidden messages (they should not be visible in chat UI)
const visibleHistory = (this.chat?.history ?? []).filter((history) => !history.hidden);
const data = await Promise.all(visibleHistory.map(async (history) => {
const userIdStr = history.user.id ? String(history.user.id) : "";
const prof = userIdStr
? await fetchUserProfile(userIdStr, profileCache, this.grpcClient)
: { name: null, avatar: null, email: null };
return {
user: {
id: history.user.id || null,
name: prof.name ?? null,
avatar: prof.avatar ?? null,
content: history.user.content || null,
date: history.user.createdAt,
},
assistant: history.assistant.content,
date: history.assistant.createdAt,
};
}));
return { data, error: false };
}
}
catch (error) {
throw error;
}
}
/**
* Continues a conversation with the AI Assistant.
* @category Services
* @param {IConversationData} data - Data containing the necessary parameters to continue the conversation.
* @returns {Promise<IChatResponseFormat>} The AI Assistant's response.
* @throws {Error} If something goes wrong when requesting the LLM.
*/
async continueConversation(data) {
const logger = getLogger();
try {
logger.info("Continue chat conversation.");
const userMessageDate = new Date();
let investigation = null;
let oldInvestigation = null;
if (this.investigationId) {
investigation = (await Investigation.findById(new ObjectId(this.investigationId)));
oldInvestigation = JSON.parse(JSON.stringify(investigation));
}
const assistantResponse = await this.aiAssistantService.processMessage(data.message, this.chat?.history ?? [], investigation, this.chat?.history?.at(-1)?.assistant?.lessonWithoutInvestigation ?? null);
if (!assistantResponse.message) {
const errorResponse = {
message: `Unable to respond the message: ${data.message}`,
error: true,
};
logger.info(errorResponse, "Message response with error");
return errorResponse;
}
const assistantMessageDate = new Date();
const cleanedMessage = stripMarkdown(assistantResponse.message);
const messageResponse = {
date: assistantMessageDate,
data: assistantResponse.metadata,
message: cleanedMessage,
error: false,
};
const chatHistoryEntity = {
user: {
id: this.userId,
content: data.message,
createdAt: userMessageDate,
},
assistant: {
content: cleanedMessage,
metadata: assistantResponse.metadata
? JSON.stringify(convertInvestigationFromModel(assistantResponse.metadata))
: null,
content_for_llm: assistantResponse.metadata ? "[investigation_provided]" : null,
createdAt: assistantMessageDate,
lessonWithoutInvestigation: assistantResponse.lessonWithoutInvestigation,
},
intent: assistantResponse.intent,
};
if (this.chat) {
this.chat?.history.push(chatHistoryEntity);
}
else {
this.chat = new Chat({ history: [chatHistoryEntity] });
}
if (assistantResponse.metadata) {
const metadata = assistantResponse.metadata;
let savedInvestigation;
if (!investigation || !oldInvestigation) {
// Create new investigation
if (this.userId && metadata?.metadata) {
const authorObjectId = new Types.ObjectId(this.userId.toString());
if (!metadata.metadata.author) {
metadata.metadata.author = authorObjectId;
}
}
// Ensure lastChangeWithAI is true for new investigations created from chatbot
metadata.lastChangeWithAI = true;
const newInvestigation = new Investigation(metadata);
savedInvestigation = await newInvestigation.save();
}
else {
// Update the investigation document with the new metadata
Object.assign(investigation, metadata);
// Extract isUserChangeExist from the investigation object if it was set during modification
const isUserChangeExist = investigation
._isUserChangeExist ?? false;
// Clean up the temporary property
delete investigation
._isUserChangeExist;
// Update versioning with isUserChangeExist
savedInvestigation = await this.investigationService.updateInvestigationByVersion(oldInvestigation, investigation, this.userId, isUserChangeExist);
// Process contradictions only when generating investigation on existing investigation
const isGeneratingInvestigation = assistantResponse.intent === Intents.GENERATE_INVESTIGATION ||
assistantResponse.intent === Intents.MODIFY_INVESTIGATION;
if (isGeneratingInvestigation) {
try {
const contradictionProcessor = new ContradictionProcessingService();
savedInvestigation = await contradictionProcessor.processAndApplyContradictions(savedInvestigation, async (updateFields) => {
const updateResult = await this.investigationService.updateInvestigation(savedInvestigation._id.toString(), updateFields, {
updatedBy: this.userId ? new Types.ObjectId(this.userId.toString()) : null,
isAiGenerated: false,
skipVersioning: true,
});
if (!updateResult.updatedInvestigation) {
throw new Error("Failed to update investigation during contradiction processing");
}
return { updatedInvestigation: updateResult.updatedInvestigation };
});
// Reload investigation to get the latest state after contradiction processing
savedInvestigation = await this.investigationService.getInvestigationById(savedInvestigation._id.toString());
}
catch (contradictionError) {
logger.warn({
error: contradictionError,
investigationId: savedInvestigation._id.toString(),
}, "Contradiction processing failed after generating investigation on existing investigation, but investigation was saved");
// Continue with the investigation even if contradiction detection fails
}
}
}
const dto = toInvestigationResponseDto(savedInvestigation);
const profileCache = new Map();
await enrichInvestigationResponse(savedInvestigation, dto, this.grpcClient, profileCache);
messageResponse.data = dto;
if (!this.chat.investigationId) {
this.chat.investigationId = savedInvestigation._id;
this.investigationId = savedInvestigation._id;
}
}
await this.chat?.save();
logger.info(messageResponse, "Message response");
return messageResponse;
}
catch (error) {
logger.error(error, "Error in continueConversation");
return {
message: `Something went wrong. Please try to chat later. ${String(error)}`,
error: true,
};
}
}
}
Source