Resume / backend /src /services /aiService.js
Resume Builder Dev
feat(ai): add StreamCleaner to clean suggestions output and improve prompt instructions
88db0a5
Raw
History Blame Contribute Delete
21.2 kB
const { GoogleGenerativeAI } = require('@google/generative-ai');
const axios = require('axios');
const logger = require('../config/logger');
// Heavy tasks (ATS, grammar, translate) — quality matters
const HEAVY_MODEL_CHAIN = [
'gemini-2.5-pro',
'gemini-1.5-pro',
'gemini-1.5-flash',
'gemini-pro',
];
// Fast tasks (suggest summary/description) — speed matters
const FAST_MODEL_CHAIN = [
'gemini-2.5-flash',
'gemini-1.5-flash',
'gemini-1.5-flash-8b',
'gemini-pro',
];
const COOLDOWN_DURATION = 5 * 60 * 1000; // 5 minutes cooldown for unhealthy providers
class StreamCleaner {
constructor(type) {
this.type = type;
this.buffer = '';
this.lineStart = true;
}
process(chunk, onCleanedChunk) {
if (this.type === 'summary') {
onCleanedChunk(chunk);
return;
}
this.buffer += chunk;
// Process lines
while (true) {
if (this.lineStart) {
// We are at the start of a line. We need to wait until we have a newline OR at least 10 characters
// so we can reliably strip any leading bullet points/numbers.
const newlineIndex = this.buffer.indexOf('\n');
if (newlineIndex !== -1 || this.buffer.length >= 10) {
// Strip leading bullets/numbers from the start of the buffer
this.buffer = this.buffer
.replace(/^[\s*•\-+]+/, '')
.replace(/^\d+[\.\)]\s*/, '');
this.lineStart = false;
} else {
// Wait for more chunks to decide
break;
}
}
// Now we are in the middle of a line (lineStart is false)
const newlineIndex = this.buffer.indexOf('\n');
if (newlineIndex !== -1) {
// We found a newline. Emit everything up to and including the newline.
const lineContent = this.buffer.slice(0, newlineIndex + 1);
onCleanedChunk(lineContent);
this.buffer = this.buffer.slice(newlineIndex + 1);
this.lineStart = true; // Next chars will be start of a line
} else {
// No newline, emit the entire buffer
onCleanedChunk(this.buffer);
this.buffer = '';
break;
}
}
}
flush(onCleanedChunk) {
if (this.buffer) {
if (this.lineStart) {
this.buffer = this.buffer
.replace(/^[\s*•\-+]+/, '')
.replace(/^\d+[\.\)]\s*/, '');
}
onCleanedChunk(this.buffer);
this.buffer = '';
}
}
}
class AiService {
constructor() {
this.instances = [];
this.currentKeyIndex = 0;
// Circuit breaker health flags
this.providerHealth = {
gemini: true,
grok: true,
openrouter: true
};
this.healthCooldowns = {
gemini: 0,
grok: 0,
openrouter: 0
};
this.init();
}
init() {
let rawKeys = process.env.GEMINI_API_KEYS || process.env.GEMINI_API_KEY || '';
const keys = rawKeys.split(',').map(k => k.trim()).filter(k => !!k);
this.instances = keys.map((key, idx) => {
try {
const genAI = new GoogleGenerativeAI(key);
return {
key,
genAI,
heavyModels: HEAVY_MODEL_CHAIN.map(name => ({
name,
model: genAI.getGenerativeModel({ model: name })
})),
fastModels: FAST_MODEL_CHAIN.map(name => ({
name,
model: genAI.getGenerativeModel({
model: name,
generationConfig: { maxOutputTokens: 1000, temperature: 0.7 }
})
}))
};
} catch (err) {
logger.error(`[AI] Failed to initialize key index ${idx}: ${err.message}`);
return null;
}
}).filter(inst => inst !== null);
const xaiKey = process.env.XAI_API_KEY;
const openRouterKey = process.env.OPENROUTER_API_KEY;
logger.info(`[AI Init] Gemini Keys Count: ${this.instances.length}`);
logger.info(`[AI Init] XAI Key: ${xaiKey ? 'Configured' : 'Missing'}`);
logger.info(`[AI Init] OpenRouter Key: ${openRouterKey ? 'Configured' : 'Missing'}`);
if (this.instances.length === 0) {
this.providerHealth.gemini = false;
this.healthCooldowns.gemini = Infinity; // Disable permanently if no keys
}
}
checkHealth(provider) {
if (this.providerHealth[provider]) return true;
const now = Date.now();
if (now - this.healthCooldowns[provider] > COOLDOWN_DURATION) {
logger.info(`[AI Health] Cooldown expired for ${provider}. Re-enabling...`);
this.providerHealth[provider] = true;
return true;
}
return false;
}
markUnhealthy(provider) {
logger.warn(`[AI Health] Marking provider ${provider} as UNHEALTHY due to failure. Placing on 5 min cooldown.`);
this.providerHealth[provider] = false;
this.healthCooldowns[provider] = Date.now();
}
ensureInitialized() {
// Kept for backward compatibility
}
async _generate(type, prompt) {
// 1. Try Gemini Chain (if healthy)
if (this.checkHealth('gemini') && this.instances.length > 0) {
const chainType = type === 'heavy' ? 'heavyModels' : 'fastModels';
const chainNames = type === 'heavy' ? HEAVY_MODEL_CHAIN : FAST_MODEL_CHAIN;
const instance = this.instances[this.currentKeyIndex % this.instances.length];
const modelObj = instance[chainType][0].model;
const modelName = chainNames[0];
try {
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('AI Request Timeout')), 15000)
);
const result = await Promise.race([
modelObj.generateContent(prompt),
timeoutPromise
]);
return result.response.text();
} catch (err) {
logger.error(`[AI Gemini Error] Primary attempt failed on ${modelName}: ${err.message}`);
// Mark Gemini unhealthy on structural auth/quota issues so subsequent calls bypass it instantly
const errMsg = err.message ? err.message.toLowerCase() : '';
if (errMsg.includes('not found') || errMsg.includes('404') || errMsg.includes('permission') || errMsg.includes('403') || errMsg.includes('quota') || errMsg.includes('429')) {
logger.error(`[AI Gemini Failure] Key/quota issue detected. Disabling Gemini.`);
this.markUnhealthy('gemini');
}
}
}
// 2. Fallback to Grok (xAI) (if healthy)
const xaiKey = process.env.XAI_API_KEY;
if (xaiKey && this.checkHealth('grok')) {
logger.info('[AI] Falling back to Grok...');
try {
const response = await axios.post('https://api.x.ai/v1/chat/completions', {
model: 'grok-2',
messages: [{ role: 'user', content: prompt }],
temperature: 0.7
}, {
headers: {
'Authorization': `Bearer ${xaiKey}`,
'Content-Type': 'application/json'
},
timeout: 15000
});
if (response.data?.choices?.[0]?.message?.content) {
logger.info('[AI] Successfully generated content using Grok.');
return response.data.choices[0].message.content;
}
} catch (err) {
logger.error(`[AI Grok Error] ${err.response?.data?.error?.message || err.message}`);
const status = err.response?.status;
if (status === 400 || status === 401 || status === 403 || status === 429) {
logger.error(`[AI Grok Failure] Key or credit issue detected. Disabling Grok.`);
this.markUnhealthy('grok');
}
}
}
// 3. Fallback to OpenRouter (if healthy)
const openRouterKey = process.env.OPENROUTER_API_KEY;
if (openRouterKey && this.checkHealth('openrouter')) {
logger.info('[AI] Falling back to OpenRouter...');
try {
const response = await axios.post('https://openrouter.ai/api/v1/chat/completions', {
model: 'google/gemini-2.5-flash',
messages: [{ role: 'user', content: prompt }],
temperature: 0.7,
max_tokens: 1500
}, {
headers: {
'Authorization': `Bearer ${openRouterKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://github.com/resume-builder',
'X-Title': 'Resume Builder'
},
timeout: 20000
});
if (response.data?.choices?.[0]?.message?.content) {
logger.info('[AI] Successfully generated content using OpenRouter.');
return response.data.choices[0].message.content;
}
} catch (err) {
logger.error(`[AI OpenRouter Error] ${err.response?.data?.error?.message || err.message}`);
const status = err.response?.status;
if (status === 401 || status === 403 || status === 402 || status === 429) {
logger.error(`[AI OpenRouter Failure] Limits or auth issue detected. Disabling OpenRouter.`);
this.markUnhealthy('openrouter');
}
}
}
throw new Error('All AI services (Gemini, Grok, & OpenRouter) are currently exhausted or unavailable.');
}
async analyzeAts(resumeData, jobDescription = '', targetJob = '') {
let contextStr = '';
if (jobDescription && jobDescription.trim().length > 0) {
contextStr += `Job Description: ${jobDescription}\n`;
}
if (targetJob && targetJob.trim().length > 0) {
contextStr += `Target Job Title: ${targetJob}\n`;
}
let prompt =
`Analyze this resume for ATS compatibility and impact. Provide an ATS score from 0-100. ` +
`Crucially, provide 3 to 5 highly detailed, actionable suggestions in the 'topImprovements' array on exactly how the candidate can improve their resume (e.g., specific action verbs to use, missing sections, formatting flaws, quantification of achievements). ` +
`Return ONLY a raw JSON object. Do not include any explanation or markdown formatting. ` +
`Structure: {"score":number,"grade":string,"feedback":[],"topImprovements":["Detailed suggestion 1", "Detailed suggestion 2"]}. ` +
`Resume: ${JSON.stringify(resumeData)}`;
if (contextStr) {
prompt = `Analyze this resume against the provided target job title and/or Job Description for ATS scoring. Score 0-100 based on how well the resume matches the requirements of the target role. ` +
`Crucially, provide 3 to 5 highly detailed, actionable suggestions in the 'topImprovements' array detailing exactly what skills, keywords, or experiences are missing from the resume compared to the target role/JD, and how to frame existing experience better. ` +
`Return ONLY a raw JSON object. Do not include any explanation or markdown formatting. ` +
`Structure: {"score":number,"grade":string,"feedback":[],"topImprovements":["Detailed suggestion 1", "Detailed suggestion 2"]}. ` +
`${contextStr}` +
`Resume: ${JSON.stringify(resumeData)}`;
}
const text = await this._generate('heavy', prompt);
return this._parseJson(text);
}
async translateResume(resumeData, language) {
const prompt =
`Translate all text values in this resume JSON to ${language}. ` +
`Keep the keys and structure exactly the same. ` +
`Return ONLY the raw translated JSON object. No preamble, no markdown. ` +
`JSON: ${JSON.stringify(resumeData)}`;
const text = await this._generate('heavy', prompt);
return this._parseJson(text);
}
_parseJson(text) {
try {
return JSON.parse(text.trim());
} catch (e) {
const match = text.match(/\{[\s\S]*\}/);
if (match) {
try {
return JSON.parse(match[0]);
} catch (e2) {
throw new Error('AI returned invalid JSON structure.');
}
}
throw new Error('AI did not return a valid JSON object.');
}
}
async generateSuggestionStream(type, context, onChunk) {
const prompt = this._buildPrompt(type, context);
// 1. Try Gemini Streaming (if healthy)
if (this.checkHealth('gemini') && this.instances.length > 0) {
const instance = this.instances[this.currentKeyIndex % this.instances.length];
const modelObj = instance.fastModels[0].model;
try {
const cleaner = new StreamCleaner(type);
const cleanedOnChunk = (chunk) => {
if (onChunk) onChunk(chunk);
};
const result = await modelObj.generateContentStream(prompt);
let fullText = '';
for await (const chunk of result.stream) {
const chunkText = chunk.text();
if (chunkText) {
fullText += chunkText;
cleaner.process(chunkText, cleanedOnChunk);
}
}
cleaner.flush(cleanedOnChunk);
if (fullText) return;
} catch (err) {
logger.error(`[AI Stream Gemini Error] ${err.message}`);
const errMsg = err.message ? err.message.toLowerCase() : '';
if (errMsg.includes('not found') || errMsg.includes('404') || errMsg.includes('permission') || errMsg.includes('403') || errMsg.includes('quota') || errMsg.includes('429')) {
this.markUnhealthy('gemini');
}
}
}
// 2. Try OpenRouter Streaming Fallback (if healthy)
const openRouterKey = process.env.OPENROUTER_API_KEY;
if (openRouterKey && this.checkHealth('openrouter')) {
logger.info('[AI Stream] Falling back to OpenRouter streaming...');
try {
const cleaner = new StreamCleaner(type);
const cleanedOnChunk = (chunk) => {
if (onChunk) onChunk(chunk);
};
const response = await axios.post('https://openrouter.ai/api/v1/chat/completions', {
model: 'google/gemini-2.5-flash',
messages: [{ role: 'user', content: prompt }],
temperature: 0.7,
max_tokens: 1500,
stream: true
}, {
headers: {
'Authorization': `Bearer ${openRouterKey}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://github.com/resume-builder',
'X-Title': 'Resume Builder'
},
responseType: 'stream',
timeout: 25000
});
await new Promise((resolve, reject) => {
let buffer = '';
response.data.on('data', chunk => {
buffer += chunk.toString();
const lines = buffer.split('\n');
buffer = lines.pop(); // Keep last incomplete line
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith('data: ')) continue;
const dataStr = trimmed.slice(6).trim();
if (dataStr === '[DONE]') continue;
try {
const parsed = JSON.parse(dataStr);
const content = parsed.choices?.[0]?.delta?.content;
if (content) {
cleaner.process(content, cleanedOnChunk);
}
} catch (e) {}
}
});
response.data.on('end', () => {
if (buffer.startsWith('data: ')) {
const dataStr = buffer.slice(6).trim();
if (dataStr !== '[DONE]') {
try {
const parsed = JSON.parse(dataStr);
const content = parsed.choices?.[0]?.delta?.content;
if (content) cleaner.process(content, cleanedOnChunk);
} catch (e) {}
}
}
cleaner.flush(cleanedOnChunk);
resolve();
});
response.data.on('error', err => {
reject(err);
});
});
return;
} catch (err) {
logger.error(`[AI Stream OpenRouter Error] ${err.message}`);
const status = err.response?.status;
if (status === 401 || status === 403 || status === 402 || status === 429) {
this.markUnhealthy('openrouter');
}
}
}
// 3. Fallback to Grok (xAI) Streaming (if healthy)
const xaiKey = process.env.XAI_API_KEY;
if (xaiKey && this.checkHealth('grok')) {
logger.info('[AI Stream] Falling back to Grok streaming...');
try {
const cleaner = new StreamCleaner(type);
const cleanedOnChunk = (chunk) => {
if (onChunk) onChunk(chunk);
};
const response = await axios.post('https://api.x.ai/v1/chat/completions', {
model: 'grok-2',
messages: [{ role: 'user', content: prompt }],
temperature: 0.7,
stream: true
}, {
headers: {
'Authorization': `Bearer ${xaiKey}`,
'Content-Type': 'application/json'
},
responseType: 'stream',
timeout: 25000
});
await new Promise((resolve, reject) => {
let buffer = '';
response.data.on('data', chunk => {
buffer += chunk.toString();
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith('data: ')) continue;
const dataStr = trimmed.slice(6).trim();
if (dataStr === '[DONE]') continue;
try {
const parsed = JSON.parse(dataStr);
const content = parsed.choices?.[0]?.delta?.content;
if (content) {
cleaner.process(content, cleanedOnChunk);
}
} catch (e) {}
}
});
response.data.on('end', () => {
if (buffer.startsWith('data: ')) {
const dataStr = buffer.slice(6).trim();
if (dataStr !== '[DONE]') {
try {
const parsed = JSON.parse(dataStr);
const content = parsed.choices?.[0]?.delta?.content;
if (content) cleaner.process(content, cleanedOnChunk);
} catch (e) {}
}
}
cleaner.flush(cleanedOnChunk);
resolve();
});
response.data.on('error', err => {
reject(err);
});
});
return;
} catch (err) {
logger.error(`[AI Stream Grok Error] ${err.message}`);
const status = err.response?.status;
if (status === 400 || status === 401 || status === 403 || status === 429) {
this.markUnhealthy('grok');
}
}
}
// 4. Fallback to final non-streaming if all streaming fails
logger.info('[AI Stream] All streaming failed/exhausted. Falling back to non-streaming generateSuggestion...');
try {
const fallback = await this.generateSuggestion(type, context);
if (onChunk && fallback) {
onChunk(fallback);
}
} catch (err) {
logger.error(`[AI Stream Final Fallback Error] ${err.message}`);
throw err;
}
}
cleanBulletPoints(text) {
if (!text) return '';
return text
.split('\n')
.map(line => {
let cleaned = line.trim();
cleaned = cleaned.replace(/^[\s*•\-+]+/, '');
cleaned = cleaned.replace(/^\d+[\.\)]\s*/, '');
return cleaned.trim();
})
.filter(line => line.length > 0)
.join('\n');
}
async generateSuggestion(type, context) {
const text = await this._generate('fast', this._buildPrompt(type, context));
if (type === 'summary') {
return text ? text.trim() : '';
}
return this.cleanBulletPoints(text);
}
_buildPrompt(type, context) {
if (type === 'summary') {
return `Write a cohesive, high-impact professional resume summary paragraph for: ${JSON.stringify(context)}. ` +
`Write the summary in the implied first-person format typical of resumes (e.g., 'Highly motivated professional...' instead of 'I am...' or '[Name] is...'). ` +
`Do NOT include the candidate's name or any personal pronouns like 'he', 'she', 'they', or 'I'. ` +
`Do NOT use a numbered list or bullet points. Use a premium professional tone. ` +
`Highlight key strengths, career focus, and major value proposition in 3-4 powerful sentences.`;
}
return `You are an expert resume writer. Write 3 professional, high-impact resume bullet points for: ${JSON.stringify(context)}. ` +
`Requirements:\n` +
`1. Tone & Style: Write EXACTLY like a highly-skilled human professional. Avoid typical robotic AI buzzwords and generic filler (e.g., avoid "spearheaded", "leveraged", "synergy", "cutting-edge", "dynamically"). Write with clear, active, and natural language.\n` +
`2. Structure: Each point must explain a specific responsibility and highlight a realistic, quantifiable achievement (e.g., percentages, time saved, revenue, or team size).\n` +
`3. Formatting: Output ONLY the raw bullet text itself. Each point must be on a new line. Do NOT prepend any symbols (such as asterisks *, hyphens -, bullets •, or plus signs +) or list numbers (like 1., 2.) at the start. Start each line directly with a strong action verb (e.g., "Designed", "Developed", "Optimized", "Led").`;
}
}
module.exports = new AiService();