Re_Backend/src/services/ai.service.ts

501 lines
19 KiB
TypeScript

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<void> {
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<void> {
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<string> {
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<string> {
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:
* <p>...</p> for paragraphs
* <strong>...</strong> for important text/headings
* <ul><li>...</li></ul> for bullet points
* <ol><li>...</li></ol> for numbered lists
* <br> for line breaks only when necessary
- Use semantic HTML to make the content readable and well-structured
- Example format:
<p><strong>Request Summary:</strong> [Brief description]</p>
<p><strong>Approval Decision:</strong> [Decision details]</p>
<ul>
<li>Key point 1</li>
<li>Key point 2</li>
</ul>
<p><strong>Outcome:</strong> [Final outcome]</p>
- 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();