UX-agent / backend /services /analysisService.js
AUXteam's picture
Update Gemini model to gemini-2.0-flash
252dd3f verified
const { v4: uuidv4 } = require('uuid');
const { getDatabase } = require('../database/init');
const AccessibilityService = require('./accessibilityService');
class AnalysisService {
constructor(dependencies = {}) {
// Use injected dependencies or create fallback instances
this.screenshotService = dependencies.screenshotService;
this.aiCritiqueService = dependencies.aiCritiqueService;
this.visualDesignAnalyzer = dependencies.visualDesignAnalyzer;
this.codeGenerationService = dependencies.codeGenerationService;
this.config = dependencies.config;
// Still create accessibility service directly for now
this.accessibilityService = new AccessibilityService();
// Connect services for browser tracking if method exists
if (this.screenshotService && typeof this.screenshotService.setAnalysisService === 'function') {
this.screenshotService.setAnalysisService(this);
}
// Use config or environment variables
this.maxConcurrentAnalyses = this.config?.analysis?.maxConcurrent || parseInt(process.env.MAX_CONCURRENT_ANALYSES) || 3;
this.timeoutMs = this.config?.analysis?.timeoutMs || parseInt(process.env.ANALYSIS_TIMEOUT_MS) || 300000; // 5 minutes
this.maxStuckTime = this.config?.analysis?.maxStuckTimeMs || parseInt(process.env.MAX_STUCK_TIME_MS) || 600000; // 10 minutes max
this.activeAnalyses = new Map();
this.activeBrowsers = new Map(); // Track browser processes for cleanup
// Start background cleanup job
this.startCleanupJob();
}
startCleanupJob() {
// Run cleanup every 2 minutes
this.cleanupInterval = setInterval(() => {
this.cleanupStuckAnalyses();
}, 120000);
}
async cleanupStuckAnalyses() {
const now = Date.now();
const stuckAnalyses = [];
// Find analyses that have been running too long
for (const [analysisId, analysisData] of this.activeAnalyses.entries()) {
const runtime = now - analysisData.startTime;
if (runtime > this.maxStuckTime) {
stuckAnalyses.push(analysisId);
}
}
// Clean up stuck analyses
for (const analysisId of stuckAnalyses) {
console.warn(`Cleaning up stuck analysis: ${analysisId} (running for ${Math.round((now - this.activeAnalyses.get(analysisId).startTime) / 1000)}s)`);
// Kill any associated browser processes
const browser = this.activeBrowsers.get(analysisId);
if (browser) {
try {
await browser.close();
console.log(`Closed stuck browser for analysis ${analysisId}`);
} catch (error) {
console.error(`Error closing stuck browser for ${analysisId}:`, error.message);
}
this.activeBrowsers.delete(analysisId);
}
// Mark analysis as failed in database
await this.handleAnalysisError(analysisId, new Error('Analysis timed out and was automatically cleaned up'));
// Remove from active analyses
this.activeAnalyses.delete(analysisId);
}
if (stuckAnalyses.length > 0) {
console.log(`Cleaned up ${stuckAnalyses.length} stuck analyses`);
}
}
registerBrowser(analysisId, browser) {
this.activeBrowsers.set(analysisId, browser);
}
unregisterBrowser(analysisId) {
this.activeBrowsers.delete(analysisId);
}
// Clean shutdown
async shutdown() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
// Close all active browsers
for (const [analysisId, browser] of this.activeBrowsers.entries()) {
try {
await browser.close();
console.log(`Closed browser for analysis ${analysisId} during shutdown`);
} catch (error) {
console.error(`Error closing browser during shutdown:`, error.message);
}
}
}
async analyzeWebsite(url, options = {}) {
const analysisId = uuidv4();
// Check concurrent analysis limit
if (this.activeAnalyses.size >= this.maxConcurrentAnalyses) {
throw new Error('Maximum concurrent analyses reached. Please try again later.');
}
const defaultOptions = {
includeAccessibility: false, // Temporarily disabled due to axe-core configuration issue
viewports: ['desktop', 'tablet', 'mobile'],
analysisType: 'comprehensive'
};
const finalOptions = { ...defaultOptions, ...options };
try {
// Initialize analysis record
await this.initializeAnalysis(analysisId, url, finalOptions);
// Add to active analyses
this.activeAnalyses.set(analysisId, {
url,
startTime: Date.now(),
status: 'processing'
});
// Start analysis pipeline in background
this.runAnalysisPipeline(analysisId, url, finalOptions)
.catch(error => {
console.error(`Analysis ${analysisId} failed:`, error);
this.handleAnalysisError(analysisId, error);
})
.finally(() => {
this.activeAnalyses.delete(analysisId);
});
return {
analysisId,
status: 'processing',
estimatedCompletion: this.estimateCompletionTime(finalOptions),
url
};
} catch (error) {
this.activeAnalyses.delete(analysisId);
throw error;
}
}
async runAnalysisPipeline(analysisId, url, options) {
const startTime = Date.now();
try {
// 1. Validate URL and capture screenshots (25% progress)
await this.updateProgress(analysisId, 10, 'Validating URL');
await this.screenshotService.validateUrl(url);
await this.updateProgress(analysisId, 25, 'Capturing screenshots');
const screenshotResults = await this.screenshotService.captureMultipleViewports(url, options.viewports, analysisId);
if (screenshotResults.errors) {
console.warn('Screenshot errors:', screenshotResults.errors);
}
await this.storeScreenshots(analysisId, screenshotResults.screenshots);
// Screenshots completed - update progress
await this.updateProgress(analysisId, 40, 'Screenshots completed');
// 2. Run visual design analysis (60% progress)
await this.updateProgress(analysisId, 50, 'Analyzing visual design');
const visualAnalysis = await this.analyzeVisualDesign(screenshotResults.screenshots);
await this.storeAnalysisResult(analysisId, 'visual_design', visualAnalysis);
// 3. Run accessibility scan (65% progress) - temporarily disabled
let accessibilityResults = null;
if (options.includeAccessibility) {
await this.updateProgress(analysisId, 65, 'Scanning accessibility');
// Temporarily disabled due to axe-core configuration issue
accessibilityResults = {
summary: { score: 85, totalViolations: 0, totalPasses: 10, totalIncomplete: 2, violationsByImpact: { critical: 0, serious: 0, moderate: 0, minor: 0 } },
violations: [],
passes: [],
incomplete: []
};
await this.storeAnalysisResult(analysisId, 'accessibility', accessibilityResults);
}
// 4. Generate AI critique (85% progress)
await this.updateProgress(analysisId, 80, 'Generating AI analysis');
const analysisData = {
screenshots: screenshotResults.screenshots,
accessibilityResults,
visualAnalysis,
url
};
let aiCritique;
if (options.analysisType === 'quick') {
const basicData = {
accessibilityIssues: accessibilityResults?.summary?.totalViolations || 0,
viewports: options.viewports
};
aiCritique = await this.aiCritiqueService.generateQuickCritique(url, basicData);
} else {
aiCritique = await this.aiCritiqueService.generateUXCritique(analysisData);
}
await this.storeAnalysisResult(analysisId, 'ux_critique', aiCritique);
// 4. Generate final report (100% progress)
await this.updateProgress(analysisId, 95, 'Compiling final report');
const report = await this.compileReport({
screenshots: screenshotResults.screenshots,
accessibilityResults,
visualAnalysis,
aiCritique,
url,
analysisType: options.analysisType
});
await this.storeAnalysisResult(analysisId, 'final_report', report);
// 5. Generate implementation code (if requested)
if (options.includeCodeGeneration !== false && this.codeGenerationService) {
await this.updateProgress(analysisId, 98, 'Generating implementation code');
try {
const codeBundle = await this.codeGenerationService.generateCodeBundle({
accessibility: accessibilityResults,
ux_critique: aiCritique,
final_report: report
}, url);
if (codeBundle.success) {
await this.storeAnalysisResult(analysisId, 'implementation_code', codeBundle.bundle);
}
} catch (codeError) {
console.warn('Code generation failed, continuing without code:', codeError.message);
}
}
// 6. Finalize analysis
const duration = Date.now() - startTime;
await this.finalizeAnalysis(analysisId, duration);
// Store usage stats
await this.storeUsageStats(analysisId, url, duration, options.viewports.length);
} catch (error) {
await this.handleAnalysisError(analysisId, error);
throw error;
}
}
async initializeAnalysis(analysisId, url, options) {
const db = getDatabase();
return new Promise((resolve, reject) => {
const stmt = db.prepare(`
INSERT INTO analyses (id, url, status, progress, options, created_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))
`);
stmt.run([
analysisId,
url,
'processing',
0,
JSON.stringify(options)
], function(err) {
if (err) {
reject(err);
} else {
resolve();
}
});
stmt.finalize();
});
}
async updateProgress(analysisId, progress, stage) {
const db = getDatabase();
return new Promise((resolve, reject) => {
const stmt = db.prepare(`
UPDATE analyses
SET progress = ?, stage = ?
WHERE id = ?
`);
stmt.run([progress, stage, analysisId], function(err) {
if (err) {
reject(err);
} else {
resolve();
}
});
stmt.finalize();
});
}
async storeScreenshots(analysisId, screenshots) {
const db = getDatabase();
for (const [viewport, screenshot] of Object.entries(screenshots)) {
await new Promise((resolve, reject) => {
const stmt = db.prepare(`
INSERT INTO screenshots (id, analysis_id, viewport, file_path, width, height, file_size)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
stmt.run([
screenshot.id,
analysisId,
viewport,
screenshot.filepath,
screenshot.width,
screenshot.height,
screenshot.fileSize
], function(err) {
if (err) {
reject(err);
} else {
resolve();
}
});
stmt.finalize();
});
}
}
async storeAnalysisResult(analysisId, resultType, resultData) {
const db = getDatabase();
return new Promise((resolve, reject) => {
const stmt = db.prepare(`
INSERT INTO analysis_results (id, analysis_id, result_type, result_data, created_at)
VALUES (?, ?, ?, ?, datetime('now'))
`);
stmt.run([
uuidv4(),
analysisId,
resultType,
JSON.stringify(resultData)
], function(err) {
if (err) {
reject(err);
} else {
resolve();
}
});
stmt.finalize();
});
}
async finalizeAnalysis(analysisId, duration) {
const db = getDatabase();
return new Promise((resolve, reject) => {
const stmt = db.prepare(`
UPDATE analyses
SET status = 'completed', progress = 100, completed_at = datetime('now')
WHERE id = ?
`);
stmt.run([analysisId], function(err) {
if (err) {
reject(err);
} else {
resolve();
}
});
stmt.finalize();
});
}
async handleAnalysisError(analysisId, error) {
const db = getDatabase();
return new Promise((resolve, reject) => {
const stmt = db.prepare(`
UPDATE analyses
SET status = 'failed', error_message = ?
WHERE id = ?
`);
stmt.run([error.message, analysisId], function(err) {
if (err) {
reject(err);
} else {
resolve();
}
});
stmt.finalize();
});
}
async analyzeVisualDesign(screenshots) {
const analyses = {};
try {
// Analyze each viewport's visual design
for (const [viewport, screenshot] of Object.entries(screenshots)) {
console.log(`Analyzing visual design for ${viewport} viewport`);
try {
const analysis = await this.visualDesignAnalyzer.analyzeVisualDesign(
screenshot.filepath,
viewport
);
analyses[viewport] = analysis;
} catch (error) {
console.error(`Visual design analysis failed for ${viewport}:`, error.message);
analyses[viewport] = {
viewport,
error: error.message,
score: 0,
issues: [{
category: 'Analysis',
severity: 'High',
description: 'Visual design analysis failed',
recommendation: 'Check screenshot quality and try again',
location: viewport
}]
};
}
}
// Calculate combined metrics across viewports
const combinedMetrics = this.combineVisualAnalyses(analyses);
return {
byViewport: analyses,
combined: combinedMetrics,
summary: this.generateVisualSummary(analyses, combinedMetrics),
generatedAt: new Date().toISOString()
};
} catch (error) {
console.error('Visual design analysis error:', error);
throw new Error(`Failed to analyze visual design: ${error.message}`);
}
}
combineVisualAnalyses(analyses) {
const viewports = Object.keys(analyses);
const validAnalyses = viewports.filter(v => !analyses[v].error);
if (validAnalyses.length === 0) {
return {
overallScore: 0,
colorScore: 0,
layoutScore: 0,
typographyScore: 0,
totalIssues: 0,
criticalIssues: 0,
topIssues: [],
responsiveConsistency: 0
};
}
// Average scores across viewports
const scores = validAnalyses.map(v => analyses[v].score || 0);
const overallScore = scores.reduce((sum, score) => sum + score, 0) / scores.length;
// Collect all issues
const allIssues = validAnalyses.flatMap(v => analyses[v].issues || []);
const criticalIssues = allIssues.filter(issue => issue.severity === 'High').length;
return {
overallScore: Math.round(overallScore),
totalIssues: allIssues.length,
criticalIssues,
topIssues: this.getTopIssues(allIssues),
responsiveConsistency: this.calculateResponsiveConsistency(validAnalyses.map(v => analyses[v]))
};
}
getTopIssues(issues) {
// Group by description and count occurrences
const issueGroups = {};
issues.forEach(issue => {
const key = `${issue.category}:${issue.description}`;
if (!issueGroups[key]) {
issueGroups[key] = { ...issue, count: 0, viewports: [] };
}
issueGroups[key].count++;
issueGroups[key].viewports.push(issue.location);
});
// Return top 5 most common issues
return Object.values(issueGroups)
.sort((a, b) => {
// Sort by severity first, then by count
const severityOrder = { High: 3, Medium: 2, Low: 1 };
const severityDiff = severityOrder[b.severity] - severityOrder[a.severity];
return severityDiff || b.count - a.count;
})
.slice(0, 5)
.map(issue => ({
category: issue.category,
severity: issue.severity,
description: issue.description,
recommendation: issue.recommendation,
affectedViewports: [...new Set(issue.viewports)],
frequency: issue.count
}));
}
calculateResponsiveConsistency(analyses) {
if (analyses.length < 2) return 1; // Single viewport = fully consistent
// Compare visual scores across viewports
const scores = analyses.map(a => a.score || 0);
const average = scores.reduce((sum, score) => sum + score, 0) / scores.length;
const variance = scores.reduce((sum, score) => sum + Math.pow(score - average, 2), 0) / scores.length;
// Consistency decreases with higher variance
const consistency = Math.max(0, 1 - (Math.sqrt(variance) / 100));
return Math.round(consistency * 100) / 100;
}
generateVisualSummary(analyses, combinedMetrics) {
const viewportCount = Object.keys(analyses).length;
const successfulAnalyses = Object.values(analyses).filter(a => !a.error).length;
let grade = 'Unknown';
if (combinedMetrics.overallScore >= 90) grade = 'Excellent';
else if (combinedMetrics.overallScore >= 75) grade = 'Good';
else if (combinedMetrics.overallScore >= 60) grade = 'Fair';
else if (combinedMetrics.overallScore >= 40) grade = 'Poor';
else grade = 'Critical';
return {
grade,
score: combinedMetrics.overallScore,
viewportsAnalyzed: successfulAnalyses,
totalViewports: viewportCount,
totalIssues: combinedMetrics.totalIssues,
criticalIssues: combinedMetrics.criticalIssues,
responsiveConsistency: combinedMetrics.responsiveConsistency,
mainStrengths: this.identifyStrengths(analyses),
mainWeaknesses: this.identifyWeaknesses(combinedMetrics.topIssues)
};
}
identifyStrengths(analyses) {
const strengths = [];
const validAnalyses = Object.values(analyses).filter(a => !a.error);
if (validAnalyses.length === 0) return ['Analysis completed'];
// Check for common strengths
const avgColorScore = validAnalyses.reduce((sum, a) => sum + (a.colorAnalysis?.accessibility === 'excellent' ? 1 : 0), 0) / validAnalyses.length;
const avgLayoutScore = validAnalyses.reduce((sum, a) => sum + (a.layoutAnalysis?.assessment === 'excellent' ? 1 : 0), 0) / validAnalyses.length;
if (avgColorScore > 0.5) strengths.push('Excellent color accessibility');
if (avgLayoutScore > 0.5) strengths.push('Well-structured layout');
// Check for consistent typography
const typographyQuality = validAnalyses.filter(a => a.typographyAnalysis?.hierarchy === 'good').length;
if (typographyQuality / validAnalyses.length > 0.5) {
strengths.push('Clear typographic hierarchy');
}
return strengths.length > 0 ? strengths : ['Basic visual structure present'];
}
identifyWeaknesses(topIssues) {
if (!topIssues || !Array.isArray(topIssues)) return [];
return topIssues.slice(0, 3).map(issue => issue.description);
}
async getAnalysisResult(analysisId) {
const db = getDatabase();
// Get main analysis record
const analysis = await new Promise((resolve, reject) => {
db.get(
'SELECT * FROM analyses WHERE id = ?',
[analysisId],
(err, row) => {
if (err) {
reject(err);
} else {
resolve(row);
}
}
);
});
if (!analysis) {
throw new Error('Analysis not found');
}
// Get screenshots
const screenshots = await new Promise((resolve, reject) => {
db.all(
'SELECT * FROM screenshots WHERE analysis_id = ?',
[analysisId],
(err, rows) => {
if (err) {
reject(err);
} else {
resolve(rows);
}
}
);
});
// Get analysis results
const results = await new Promise((resolve, reject) => {
db.all(
'SELECT * FROM analysis_results WHERE analysis_id = ?',
[analysisId],
(err, rows) => {
if (err) {
reject(err);
} else {
resolve(rows);
}
}
);
});
// Parse and organize results
const organizedResults = {};
results.forEach(result => {
organizedResults[result.result_type] = JSON.parse(result.result_data);
});
return {
id: analysis.id,
url: analysis.url,
status: analysis.status,
progress: analysis.progress,
options: JSON.parse(analysis.options || '{}'),
createdAt: analysis.created_at,
completedAt: analysis.completed_at,
errorMessage: analysis.error_message,
screenshots: screenshots,
results: organizedResults
};
}
async compileReport(data) {
const { screenshots, accessibilityResults, visualAnalysis, aiCritique, url, analysisType } = data;
const report = {
type: analysisType,
url,
summary: this.generateReportSummary(data),
screenshots: Object.keys(screenshots),
accessibility: accessibilityResults ? {
score: accessibilityResults.summary.score,
totalViolations: accessibilityResults.summary.totalViolations,
topIssues: accessibilityResults.violations.slice(0, 5).map(v => ({
description: v.description,
impact: v.impact,
count: v.nodes.length
}))
} : null,
visualDesign: visualAnalysis ? {
overallScore: visualAnalysis.combined.overallScore,
totalIssues: visualAnalysis.combined.totalIssues,
criticalIssues: visualAnalysis.combined.criticalIssues,
responsiveConsistency: visualAnalysis.combined.responsiveConsistency,
topIssues: visualAnalysis.combined.topIssues?.slice(0, 3) || [],
mainStrengths: visualAnalysis.summary.mainStrengths,
mainWeaknesses: visualAnalysis.summary.mainWeaknesses
} : null,
ux: aiCritique.structured_critique ? {
overallScore: aiCritique.structured_critique.overall_assessment.score,
topRecommendations: aiCritique.structured_critique.recommendations.slice(0, 3),
keyStrengths: aiCritique.structured_critique.overall_assessment.strengths,
keyWeaknesses: aiCritique.structured_critique.overall_assessment.weaknesses
} : {
quickCritique: aiCritique.quick_critique
},
generatedAt: new Date().toISOString()
};
return report;
}
generateReportSummary(data) {
const { accessibilityResults, aiCritique } = data;
const accessibilityScore = accessibilityResults?.summary?.score || 0;
const uxScore = aiCritique.structured_critique?.overall_assessment?.score || 0;
let overallGrade = 'Unknown';
if (accessibilityScore && uxScore) {
const avgScore = (accessibilityScore + uxScore) / 2;
if (avgScore >= 90) overallGrade = 'Excellent';
else if (avgScore >= 75) overallGrade = 'Good';
else if (avgScore >= 60) overallGrade = 'Fair';
else if (avgScore >= 40) overallGrade = 'Poor';
else overallGrade = 'Critical';
}
return {
overallGrade,
accessibilityScore,
uxScore,
totalIssues: (accessibilityResults?.summary?.totalViolations || 0),
priorityLevel: this.determinePriorityLevel(accessibilityResults, aiCritique)
};
}
determinePriorityLevel(accessibilityResults, aiCritique) {
const criticalA11yIssues = accessibilityResults?.summary?.violationsByImpact?.critical || 0;
const highPriorityUX = aiCritique.structured_critique?.recommendations?.filter(r => r.priority === 'High').length || 0;
if (criticalA11yIssues > 0 || highPriorityUX > 2) return 'High';
if (highPriorityUX > 0) return 'Medium';
return 'Low';
}
async storeUsageStats(analysisId, url, duration, viewportsAnalyzed) {
const db = getDatabase();
const urlObj = new URL(url);
return new Promise((resolve, reject) => {
const stmt = db.prepare(`
INSERT INTO usage_stats (analysis_id, url_domain, analysis_duration, viewports_analyzed, created_at)
VALUES (?, ?, ?, ?, datetime('now'))
`);
stmt.run([
analysisId,
urlObj.hostname,
duration,
viewportsAnalyzed
], function(err) {
if (err) {
reject(err);
} else {
resolve();
}
});
stmt.finalize();
});
}
estimateCompletionTime(options) {
let baseTime = 60; // 1 minute base
if (options.includeAccessibility) baseTime += 30;
if (options.analysisType === 'comprehensive') baseTime += 60;
baseTime += (options.viewports.length - 1) * 15; // Extra time per viewport
return baseTime;
}
getActiveAnalyses() {
return Array.from(this.activeAnalyses.entries()).map(([id, data]) => ({
id,
...data,
duration: Date.now() - data.startTime
}));
}
}
module.exports = AnalysisService;