501 lines
19 KiB
TypeScript
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();
|