Source

services/chat.service.js

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,
            };
        }
    }
}