import logger, { logAIEvent } from '@utils/logger'; import { getConfigValue } from './configReader.service'; import { VertexAI } from '@google-cloud/vertexai'; import { resolve } from 'path'; // Vertex AI Configuration const PROJECT_ID = process.env.GCP_PROJECT_ID || 're-platform-workflow-dealer'; const LOCATION = process.env.VERTEX_AI_LOCATION || 'asia-south1'; const KEY_FILE_PATH = process.env.GCP_KEY_FILE || resolve(__dirname, '../../credentials/re-platform-workflow-dealer-3d5738fcc1f9.json'); const DEFAULT_MODEL = 'gemini-2.5-flash'; class AIService { private vertexAI: VertexAI | null = null; private model: string = DEFAULT_MODEL; private isInitialized: boolean = false; private providerName: string = 'Vertex AI (Gemini)'; constructor() { // Initialization happens asynchronously this.initialize(); } /** * Initialize Vertex AI client */ async initialize(): Promise { try { // Check if AI is enabled from config const { getConfigBoolean } = require('./configReader.service'); const enabled = await getConfigBoolean('AI_ENABLED', true); if (!enabled) { logger.warn('[AI Service] AI features disabled in admin configuration'); this.isInitialized = true; return; } // Get model and location from environment variables only this.model = process.env.VERTEX_AI_MODEL || DEFAULT_MODEL; const location = process.env.VERTEX_AI_LOCATION || LOCATION; logger.info(`[AI Service] Initializing Vertex AI with project: ${PROJECT_ID}, location: ${location}, model: ${this.model}`); // Initialize Vertex AI client with service account credentials this.vertexAI = new VertexAI({ project: PROJECT_ID, location: location, googleAuthOptions: { keyFilename: KEY_FILE_PATH, }, }); logger.info(`[AI Service] ✅ Vertex AI provider initialized successfully with model: ${this.model}`); this.isInitialized = true; } catch (error: any) { logger.error('[AI Service] Failed to initialize Vertex AI:', error); if (error.code === 'MODULE_NOT_FOUND') { logger.warn('[AI Service] @google-cloud/vertexai package not installed. Run: npm install @google-cloud/vertexai'); } else if (error.message?.includes('ENOENT') || error.message?.includes('not found')) { logger.error(`[AI Service] Service account key file not found at: ${KEY_FILE_PATH}`); logger.error('[AI Service] Please ensure the credentials file exists and GCP_KEY_FILE path is correct'); } else if (error.message?.includes('Could not load the default credentials')) { logger.error('[AI Service] Failed to load service account credentials. Please verify the key file is valid.'); } else { logger.error(`[AI Service] Initialization error: ${error.message}`); } this.isInitialized = true; // Mark as initialized even if failed to prevent infinite loops } } /** * Reinitialize AI provider (call after admin updates config) */ async reinitialize(): Promise { logger.info('[AI Service] Reinitializing Vertex AI provider from updated configuration...'); this.vertexAI = null; this.isInitialized = false; await this.initialize(); } /** * Get current AI provider name */ getProviderName(): string { return this.providerName; } /** * Generate text using Vertex AI Gemini */ private async generateText(prompt: string): Promise { if (!this.vertexAI) { throw new Error('Vertex AI client not initialized'); } logAIEvent('request', { provider: 'vertex-ai', model: this.model }); try { // Get the generative model const generativeModel = this.vertexAI.getGenerativeModel({ model: this.model, generationConfig: { maxOutputTokens: 2048, temperature: 0.3, }, }); // Generate content const request = { contents: [{ role: 'user', parts: [{ text: prompt }] }], }; const streamingResp = await generativeModel.generateContent(request); const response = streamingResp.response; // Log full response structure for debugging if empty if (!response.candidates || response.candidates.length === 0) { logger.error('[AI Service] No candidates in Vertex AI response:', { response: JSON.stringify(response, null, 2), promptLength: prompt.length, model: this.model }); throw new Error('Vertex AI returned no candidates. The response may have been blocked by safety filters.'); } const candidate = response.candidates[0]; // Check for safety ratings or blocked reasons if (candidate.safetyRatings && candidate.safetyRatings.length > 0) { const blockedRatings = candidate.safetyRatings.filter((rating: any) => rating.probability === 'HIGH' || rating.probability === 'MEDIUM' ); if (blockedRatings.length > 0) { logger.warn('[AI Service] Vertex AI safety filters triggered:', { ratings: blockedRatings.map((r: any) => ({ category: r.category, probability: r.probability })), finishReason: candidate.finishReason }); } } // Check finish reason if (candidate.finishReason && candidate.finishReason !== 'STOP') { logger.warn('[AI Service] Vertex AI finish reason:', { finishReason: candidate.finishReason, safetyRatings: candidate.safetyRatings }); } // Extract text from response const text = candidate.content?.parts?.[0]?.text || ''; if (!text) { // Log detailed response structure for debugging logger.error('[AI Service] Empty text in Vertex AI response:', { candidate: JSON.stringify(candidate, null, 2), finishReason: candidate.finishReason, safetyRatings: candidate.safetyRatings, promptLength: prompt.length, promptPreview: prompt.substring(0, 200) + '...', model: this.model }); // Provide more helpful error message if (candidate.finishReason === 'SAFETY') { throw new Error('Vertex AI blocked the response due to safety filters. The prompt may contain content that violates safety policies.'); } else if (candidate.finishReason === 'MAX_TOKENS') { throw new Error('Vertex AI response was truncated due to token limit.'); } else if (candidate.finishReason === 'RECITATION') { throw new Error('Vertex AI blocked the response due to recitation concerns.'); } else { throw new Error(`Empty response from Vertex AI. Finish reason: ${candidate.finishReason || 'UNKNOWN'}`); } } return text; } catch (error: any) { logger.error('[AI Service] Vertex AI generation error:', error); // Provide more specific error messages if (error.message?.includes('Model was not found')) { throw new Error(`Model ${this.model} not found or not available in region ${LOCATION}. Please check model name and region.`); } else if (error.message?.includes('Permission denied')) { throw new Error('Permission denied. Please verify service account has Vertex AI User role.'); } else if (error.message?.includes('API not enabled')) { throw new Error('Vertex AI API is not enabled. Please enable it in Google Cloud Console.'); } throw new Error(`Vertex AI generation failed: ${error.message}`); } } /** * Generate conclusion remark for a workflow request * @param context - All relevant data for generating the conclusion * @returns AI-generated conclusion remark */ async generateConclusionRemark(context: { requestTitle: string; requestDescription: string; requestNumber: string; priority: string; approvalFlow: Array<{ levelNumber: number; approverName: string; status: string; comments?: string; actionDate?: string; tatHours?: number; elapsedHours?: number; }>; workNotes: Array<{ userName: string; message: string; createdAt: string; }>; documents: Array<{ fileName: string; uploadedBy: string; uploadedAt: string; }>; activities: Array<{ type: string; action: string; details: string; timestamp: string; }>; }): Promise<{ remark: string; confidence: number; keyPoints: string[]; provider: string }> { // Ensure initialization is complete if (!this.isInitialized) { logger.warn('[AI Service] Not yet initialized, attempting initialization...'); await this.initialize(); } if (!this.vertexAI) { logger.error('[AI Service] Vertex AI not available'); throw new Error('AI features are currently unavailable. Please verify Vertex AI configuration and service account credentials.'); } try { // Build context prompt with max length from config const prompt = await this.buildConclusionPrompt(context); logger.info(`[AI Service] Generating conclusion for request ${context.requestNumber} using ${this.providerName} (${this.model})...`); // Use Vertex AI to generate text let remarkText = await this.generateText(prompt); // Get max length from config for logging const maxLengthStr = await getConfigValue('AI_MAX_REMARK_LENGTH', '2000'); const maxLength = parseInt(maxLengthStr || '2000', 10); // Log length (no trimming - preserve complete AI-generated content) if (remarkText.length > maxLength) { logger.warn(`[AI Service] ⚠️ AI exceeded suggested limit (${remarkText.length} > ${maxLength}). Content preserved to avoid incomplete information.`); } // Extract key points (look for bullet points or numbered items) const keyPoints = this.extractKeyPoints(remarkText); // Calculate confidence based on response quality (simple heuristic) const confidence = this.calculateConfidence(remarkText, context); logger.info(`[AI Service] ✅ Generated conclusion (${remarkText.length}/${maxLength} chars, ${keyPoints.length} key points) via ${this.providerName}`); return { remark: remarkText, confidence: confidence, keyPoints: keyPoints, provider: this.providerName }; } catch (error: any) { logger.error('[AI Service] Failed to generate conclusion:', error); throw new Error(`AI generation failed (${this.providerName}): ${error.message}`); } } /** * Build the prompt for Vertex AI to generate a professional conclusion remark */ private async buildConclusionPrompt(context: any): Promise { const { requestTitle, requestDescription, requestNumber, priority, approvalFlow, workNotes, documents, activities, rejectionReason, rejectedBy } = context; // Get max remark length from admin configuration const maxLengthStr = await getConfigValue('AI_MAX_REMARK_LENGTH', '2000'); const maxLength = parseInt(maxLengthStr || '2000', 10); const targetWordCount = Math.floor(maxLength / 6); // Approximate words (avg 6 chars per word) logger.info(`[AI Service] Using max remark length: ${maxLength} characters (≈${targetWordCount} words) from admin config`); // Check if this is a rejected request const isRejected = rejectionReason || rejectedBy || approvalFlow.some((a: any) => a.status === 'REJECTED'); // Helper function to determine TAT risk status const getTATRiskStatus = (tatPercentage: number): string => { if (tatPercentage < 50) return 'ON_TRACK'; if (tatPercentage < 75) return 'AT_RISK'; if (tatPercentage < 100) return 'CRITICAL'; return 'BREACHED'; }; // Summarize approvals with TAT risk information const approvalSummary = approvalFlow .filter((a: any) => a.status === 'APPROVED' || a.status === 'REJECTED') .map((a: any) => { const tatPercentage = a.tatPercentageUsed !== undefined && a.tatPercentageUsed !== null ? Number(a.tatPercentageUsed) : (a.elapsedHours && a.tatHours ? (Number(a.elapsedHours) / Number(a.tatHours)) * 100 : 0); const riskStatus = getTATRiskStatus(tatPercentage); const tatInfo = a.elapsedHours && a.tatHours ? ` (completed in ${a.elapsedHours.toFixed(1)}h of ${a.tatHours}h TAT, ${tatPercentage.toFixed(1)}% used)` : ''; const riskInfo = riskStatus !== 'ON_TRACK' ? ` [${riskStatus}]` : ''; return `- Level ${a.levelNumber}: ${a.approverName} ${a.status}${tatInfo}${riskInfo}${a.comments ? `\n Comment: "${a.comments}"` : ''}`; }) .join('\n'); // Summarize work notes (limit to important ones) const workNoteSummary = workNotes .slice(-10) // Last 10 work notes .map((wn: any) => `- ${wn.userName}: "${wn.message.substring(0, 150)}${wn.message.length > 150 ? '...' : ''}"`) .join('\n'); // Summarize documents const documentSummary = documents .map((d: any) => `- ${d.fileName} (by ${d.uploadedBy})`) .join('\n'); // Build rejection context if applicable const rejectionContext = isRejected ? `\n**Rejection Details:**\n- Rejected by: ${rejectedBy || 'Approver'}\n- Rejection reason: ${rejectionReason || 'Not specified'}` : ''; const prompt = `You are writing a closure summary for a workflow request at Royal Enfield. Write a practical, realistic conclusion that an employee would write when closing a request. **Request:** ${requestNumber} - ${requestTitle} Description: ${requestDescription} Priority: ${priority} **What Happened:** ${approvalSummary || 'No approvals recorded'}${rejectionContext} **Discussions (if any):** ${workNoteSummary || 'No work notes'} **Documents:** ${documentSummary || 'No documents'} **YOUR TASK:** Write a brief, professional conclusion (approximately ${targetWordCount} words, max ${maxLength} characters) that: ${isRejected ? `- Summarizes what was requested and explains that it was rejected - Mentions who rejected it and the rejection reason - Notes the outcome and any learnings or next steps - Mentions if any approval levels were AT_RISK, CRITICAL, or BREACHED (if applicable) - Uses clear, factual language without time-specific references - Is suitable for permanent archiving and future reference - Sounds natural and human-written (not AI-generated) - Maintains a professional and constructive tone even for rejections` : `- Summarizes what was requested and the final decision - Mentions who approved it and any key comments - Mentions if any approval levels were AT_RISK, CRITICAL, or BREACHED (if applicable) - Notes the outcome and next steps (if applicable) - Uses clear, factual language without time-specific references - Is suitable for permanent archiving and future reference - Sounds natural and human-written (not AI-generated)`} **CRITICAL CHARACTER LIMIT - STRICT REQUIREMENT:** - Your response MUST be EXACTLY within ${maxLength} characters (not words, CHARACTERS including spaces) - Count your characters carefully before responding - If you have too much content, PRIORITIZE the most important information: 1. Final decision (approved/rejected) 2. Key approvers and their decisions 3. Critical TAT breaches (if any) 4. Brief summary of the request - OMIT less important details to fit within the limit rather than exceeding it - Better to be concise than to exceed the limit **WRITING GUIDELINES:** - Be concise and direct - every word must add value - No time-specific words like "today", "now", "currently", "recently" - No corporate jargon or buzzwords - No emojis - Write like a professional documenting a completed process - Focus on facts: what was requested, who ${isRejected ? 'rejected' : 'approved'}, what was decided - Use past tense for completed actions - Use short sentences and avoid filler words **FORMAT REQUIREMENT - HTML Rich Text:** - Generate content in HTML format for rich text editor display - Use proper HTML tags for structure and formatting: *

...

for paragraphs * ... for important text/headings *
  • ...
for bullet points *
  1. ...
for numbered lists *
for line breaks only when necessary - Use semantic HTML to make the content readable and well-structured - Example format:

Request Summary: [Brief description]

Approval Decision: [Decision details]

  • Key point 1
  • Key point 2

Outcome: [Final outcome]

- Keep HTML clean and minimal - no inline styles, no divs, no classes - The HTML should render nicely in a rich text editor Write the conclusion now in HTML format. STRICT LIMIT: ${maxLength} characters maximum (including HTML tags). Prioritize and condense if needed:`; return prompt; } /** * Extract key points from the AI-generated remark */ private extractKeyPoints(remark: string): string[] { const keyPoints: string[] = []; // Look for bullet points (-, •, *) or numbered items (1., 2., etc.) const lines = remark.split('\n'); for (const line of lines) { const trimmed = line.trim(); // Match bullet points if (trimmed.match(/^[-•*]\s+(.+)$/)) { const point = trimmed.replace(/^[-•*]\s+/, ''); if (point.length > 10) { // Ignore very short lines keyPoints.push(point); } } // Match numbered items if (trimmed.match(/^\d+\.\s+(.+)$/)) { const point = trimmed.replace(/^\d+\.\s+/, ''); if (point.length > 10) { keyPoints.push(point); } } } // If no bullet points found, extract first few sentences if (keyPoints.length === 0) { const sentences = remark.split(/[.!?]+/).filter(s => s.trim().length > 20); keyPoints.push(...sentences.slice(0, 3).map(s => s.trim())); } return keyPoints.slice(0, 5); // Max 5 key points } /** * Calculate confidence score based on response quality */ private calculateConfidence(remark: string, context: any): number { let score = 0.6; // Base score // Check if remark has good length (100-400 chars - more realistic) if (remark.length >= 100 && remark.length <= 400) { score += 0.2; } // Check if remark mentions key elements if (remark.toLowerCase().includes('approv')) { score += 0.1; } // Check if remark is not too generic if (remark.length > 80 && !remark.toLowerCase().includes('lorem ipsum')) { score += 0.1; } return Math.min(1.0, score); } /** * Check if AI service is available */ isAvailable(): boolean { return this.vertexAI !== null; } } export const aiService = new AIService();