diff --git a/PathFinder.DatabaseMigrator/Migrations/020_Add_AiAnalytics_Tables.sql b/PathFinder.DatabaseMigrator/Migrations/020_Add_AiAnalytics_Tables.sql new file mode 100644 index 0000000..0059346 --- /dev/null +++ b/PathFinder.DatabaseMigrator/Migrations/020_Add_AiAnalytics_Tables.sql @@ -0,0 +1,162 @@ +-- ============================================= +-- Migration: AI Analytics Tables for Job Matching & CV Analysis +-- Author: Your Name +-- Date: 2026-04-16 +-- Description: Adds tables for AI-powered features including: +-- - CV analysis results +-- - Job match analytics +-- - Applicant screening +-- - Analytics history snapshots +-- ============================================= + +BEGIN TRY + BEGIN TRANSACTION; + + -- Table 1: CV Analysis Results (stores ATS scoring) + IF OBJECT_ID('dbo.cv_analysis_results', 'U') IS NULL + BEGIN + CREATE TABLE dbo.cv_analysis_results ( + id INT IDENTITY(1,1) PRIMARY KEY, + student_id INT NOT NULL, + job_id INT NULL, + ats_score INT NOT NULL, + match_percentage INT NULL, + strengths NVARCHAR(MAX) NULL, + suggestions NVARCHAR(MAX) NULL, + missing_keywords NVARCHAR(MAX) NULL, + present_keywords NVARCHAR(MAX) NULL, + formatting_feedback NVARCHAR(MAX) NULL, + recommendation NVARCHAR(200) NULL, + analysis_type NVARCHAR(50) NOT NULL DEFAULT 'Standalone', + created_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), + + CONSTRAINT FK_cv_analysis_student + FOREIGN KEY (student_id) REFERENCES dbo.students(id) ON DELETE CASCADE, + CONSTRAINT FK_cv_analysis_job + FOREIGN KEY (job_id) REFERENCES dbo.jobs(id) ON DELETE SET NULL, + CONSTRAINT CK_analysis_type + CHECK (analysis_type IN ('Standalone', 'JobSpecific')) + ); + + CREATE INDEX IX_cv_analysis_student_id ON dbo.cv_analysis_results(student_id); + CREATE INDEX IX_cv_analysis_job_id ON dbo.cv_analysis_results(job_id); + CREATE INDEX IX_cv_analysis_created_at ON dbo.cv_analysis_results(created_at); + + PRINT '✅ Created cv_analysis_results table'; + END + ELSE + PRINT 'cv_analysis_results table already exists'; + + -- Table 2: Job Match Analytics (stores student-job matching scores) + IF OBJECT_ID('dbo.job_match_analytics', 'U') IS NULL + BEGIN + CREATE TABLE dbo.job_match_analytics ( + id INT IDENTITY(1,1) PRIMARY KEY, + job_id INT NOT NULL, + student_id INT NOT NULL, + match_score INT NOT NULL, + matched_skills NVARCHAR(MAX) NULL, + missing_skills NVARCHAR(MAX) NULL, + partial_matches NVARCHAR(MAX) NULL, + recommendation NVARCHAR(200) NULL, + calculated_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), + + CONSTRAINT FK_match_job + FOREIGN KEY (job_id) REFERENCES dbo.jobs(id) ON DELETE CASCADE, + CONSTRAINT FK_match_student + FOREIGN KEY (student_id) REFERENCES dbo.students(id) ON DELETE CASCADE + ); + + CREATE INDEX IX_job_match_job_id ON dbo.job_match_analytics(job_id); + CREATE INDEX IX_job_match_student_id ON dbo.job_match_analytics(student_id); + CREATE INDEX IX_job_match_calculated_at ON dbo.job_match_analytics(calculated_at); + + PRINT '✅ Created job_match_analytics table'; + END + ELSE + PRINT 'job_match_analytics table already exists'; + + -- Table 3: Applicant Screening (stores AI screening results) + IF OBJECT_ID('dbo.applicant_screening', 'U') IS NULL + BEGIN + CREATE TABLE dbo.applicant_screening ( + id INT IDENTITY(1,1) PRIMARY KEY, + application_id INT NOT NULL, + screening_score INT NOT NULL, + screening_recommendation NVARCHAR(100) NULL, + ai_analysis_json NVARCHAR(MAX) NULL, + screened_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), + + CONSTRAINT FK_screening_application + FOREIGN KEY (application_id) REFERENCES dbo.applications(id) ON DELETE CASCADE + ); + + CREATE INDEX IX_applicant_screening_application_id ON dbo.applicant_screening(application_id); + CREATE INDEX IX_applicant_screening_score ON dbo.applicant_screening(screening_score); + + PRINT '✅ Created applicant_screening table'; + END + ELSE + PRINT 'applicant_screening table already exists'; + + -- Table 4: Analytics History (for trends and reporting) + IF OBJECT_ID('dbo.analytics_history', 'U') IS NULL + BEGIN + CREATE TABLE dbo.analytics_history ( + id INT IDENTITY(1,1) PRIMARY KEY, + snapshot_date DATE NOT NULL, + total_students INT NOT NULL DEFAULT 0, + active_companies INT NOT NULL DEFAULT 0, + active_jobs INT NOT NULL DEFAULT 0, + total_applications INT NOT NULL DEFAULT 0, + avg_ats_score DECIMAL(5,2) NULL, + avg_match_percentage DECIMAL(5,2) NULL, + application_success_rate DECIMAL(5,2) NULL, + top_skills NVARCHAR(MAX) NULL, + created_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), + + CONSTRAINT UQ_analytics_snapshot_date UNIQUE (snapshot_date) + ); + + CREATE INDEX IX_analytics_history_snapshot_date ON dbo.analytics_history(snapshot_date); + + PRINT '✅ Created analytics_history table'; + END + ELSE + PRINT 'analytics_history table already exists'; + + -- Verify all tables were created + DECLARE @TablesCreated TABLE (TableName NVARCHAR(100)); + + INSERT INTO @TablesCreated (TableName) + SELECT name FROM sys.objects + WHERE name IN ('cv_analysis_results', 'job_match_analytics', 'applicant_screening', 'analytics_history') + AND type = 'U'; + + SELECT * FROM @TablesCreated; + + COMMIT TRANSACTION; + PRINT '✅ AI Analytics migration completed successfully!'; + + -- Return summary + SELECT + COUNT(*) as TablesCreated + FROM @TablesCreated; + +END TRY +BEGIN CATCH + ROLLBACK TRANSACTION; + PRINT '❌ Error: ' + ERROR_MESSAGE(); + PRINT '❌ Error Line: ' + CAST(ERROR_LINE() AS NVARCHAR(10)); + PRINT '❌ Error Procedure: ' + ISNULL(ERROR_PROCEDURE(), 'N/A'); + THROW; +END CATCH +GO + +-- Verify the tables exist after migration +SELECT + TABLE_NAME, + TABLE_TYPE +FROM INFORMATION_SCHEMA.TABLES +WHERE TABLE_NAME IN ('cv_analysis_results', 'job_match_analytics', 'applicant_screening', 'analytics_history') +ORDER BY TABLE_NAME; \ No newline at end of file diff --git a/Program.cs b/Program.cs index 71ff852..0069c9d 100644 --- a/Program.cs +++ b/Program.cs @@ -8,6 +8,11 @@ var builder = WebApplication.CreateBuilder(args); +// Add logging +builder.Logging.ClearProviders(); +builder.Logging.AddConsole(); +builder.Logging.AddDebug(); + // Add MVC controllers + Swagger builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); @@ -144,7 +149,6 @@ // Enable static files to serve uploaded images app.UseStaticFiles(); - // Swagger UI in Development and Production environments if (app.Environment.IsDevelopment() || app.Environment.IsProduction()) { @@ -169,13 +173,14 @@ // Health endpoint for monitoring/testing app.MapGet("/health", () => Results.Ok(new { status = "Healthy", timestamp = DateTime.Now })); -// Seed default admin if enabled (idempotent) +// Seed default admin if enabled (idempotent) and create AI tables // Credentials should be provided via environment variables to avoid hardcoding secrets. using (var scope = app.Services.CreateScope()) { var config = scope.ServiceProvider.GetRequiredService(); var adminRepo = scope.ServiceProvider.GetRequiredService(); var pwd = scope.ServiceProvider.GetRequiredService(); + var logger = scope.ServiceProvider.GetRequiredService>(); var seedEnabled = config.GetValue("AdminSeed:Enabled", true); if (seedEnabled) @@ -195,8 +200,21 @@ { // Hash password before inserting (never store plain password) await adminRepo.EnsureSeedAdminAsync(seedFullName, seedEmail, pwd.Hash(seedPassword)); + app.Logger.LogInformation("Admin seeded successfully"); } } + + // Create AI analytics tables if they don't exist + try + { + var aiRepo = scope.ServiceProvider.GetRequiredService(); + await aiRepo.EnsureAiTablesExistAsync(); + app.Logger.LogInformation("AI analytics tables verified/created successfully"); + } + catch (Exception ex) + { + app.Logger.LogWarning(ex, "Failed to ensure AI analytics tables exist. AI features may not work properly."); + } } -app.Run(); +app.Run(); \ No newline at end of file diff --git a/Repositories/AiAnalyticsRepository.cs b/Repositories/AiAnalyticsRepository.cs index d72c022..142db9d 100644 --- a/Repositories/AiAnalyticsRepository.cs +++ b/Repositories/AiAnalyticsRepository.cs @@ -14,6 +14,116 @@ public AiAnalyticsRepository(Db db) _db = db; } + // ========== NEW METHOD - ADD THIS ========== + /// + /// Ensures all AI analytics tables exist (creates them if missing) + /// Call this during app startup + /// + public async Task EnsureAiTablesExistAsync() + { + var sql = @" + -- Table 1: CV Analysis Results + IF OBJECT_ID('dbo.cv_analysis_results', 'U') IS NULL + BEGIN + CREATE TABLE dbo.cv_analysis_results ( + id INT IDENTITY(1,1) PRIMARY KEY, + student_id INT NOT NULL, + job_id INT NULL, + ats_score INT NOT NULL, + match_percentage INT NULL, + strengths NVARCHAR(MAX) NULL, + suggestions NVARCHAR(MAX) NULL, + missing_keywords NVARCHAR(MAX) NULL, + present_keywords NVARCHAR(MAX) NULL, + formatting_feedback NVARCHAR(MAX) NULL, + recommendation NVARCHAR(200) NULL, + analysis_type NVARCHAR(50) NOT NULL DEFAULT 'Standalone', + created_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), + + CONSTRAINT FK_cv_analysis_student + FOREIGN KEY (student_id) REFERENCES dbo.students(id) ON DELETE CASCADE, + CONSTRAINT FK_cv_analysis_job + FOREIGN KEY (job_id) REFERENCES dbo.jobs(id) ON DELETE SET NULL + ); + + CREATE INDEX IX_cv_analysis_student_id ON dbo.cv_analysis_results(student_id); + CREATE INDEX IX_cv_analysis_job_id ON dbo.cv_analysis_results(job_id); + CREATE INDEX IX_cv_analysis_created_at ON dbo.cv_analysis_results(created_at); + END + + -- Table 2: Job Match Analytics + IF OBJECT_ID('dbo.job_match_analytics', 'U') IS NULL + BEGIN + CREATE TABLE dbo.job_match_analytics ( + id INT IDENTITY(1,1) PRIMARY KEY, + job_id INT NOT NULL, + student_id INT NOT NULL, + match_score INT NOT NULL, + matched_skills NVARCHAR(MAX) NULL, + missing_skills NVARCHAR(MAX) NULL, + partial_matches NVARCHAR(MAX) NULL, + recommendation NVARCHAR(200) NULL, + calculated_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), + + CONSTRAINT FK_match_job + FOREIGN KEY (job_id) REFERENCES dbo.jobs(id) ON DELETE CASCADE, + CONSTRAINT FK_match_student + FOREIGN KEY (student_id) REFERENCES dbo.students(id) ON DELETE CASCADE + ); + + CREATE INDEX IX_job_match_job_id ON dbo.job_match_analytics(job_id); + CREATE INDEX IX_job_match_student_id ON dbo.job_match_analytics(student_id); + CREATE INDEX IX_job_match_calculated_at ON dbo.job_match_analytics(calculated_at); + END + + -- Table 3: Applicant Screening + IF OBJECT_ID('dbo.applicant_screening', 'U') IS NULL + BEGIN + CREATE TABLE dbo.applicant_screening ( + id INT IDENTITY(1,1) PRIMARY KEY, + application_id INT NOT NULL, + screening_score INT NOT NULL, + screening_recommendation NVARCHAR(100) NULL, + ai_analysis_json NVARCHAR(MAX) NULL, + screened_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), + + CONSTRAINT FK_screening_application + FOREIGN KEY (application_id) REFERENCES dbo.applications(id) ON DELETE CASCADE + ); + + CREATE INDEX IX_applicant_screening_application_id ON dbo.applicant_screening(application_id); + CREATE INDEX IX_applicant_screening_score ON dbo.applicant_screening(screening_score); + END + + -- Table 4: Analytics History + IF OBJECT_ID('dbo.analytics_history', 'U') IS NULL + BEGIN + CREATE TABLE dbo.analytics_history ( + id INT IDENTITY(1,1) PRIMARY KEY, + snapshot_date DATE NOT NULL, + total_students INT NOT NULL DEFAULT 0, + active_companies INT NOT NULL DEFAULT 0, + active_jobs INT NOT NULL DEFAULT 0, + total_applications INT NOT NULL DEFAULT 0, + avg_ats_score DECIMAL(5,2) NULL, + avg_match_percentage DECIMAL(5,2) NULL, + application_success_rate DECIMAL(5,2) NULL, + top_skills NVARCHAR(MAX) NULL, + created_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), + + CONSTRAINT UQ_analytics_snapshot_date UNIQUE (snapshot_date) + ); + + CREATE INDEX IX_analytics_history_snapshot_date ON dbo.analytics_history(snapshot_date); + END + "; + + using var conn = _db.CreateConnection(); + await conn.OpenAsync(); + using var cmd = new SqlCommand(sql, conn); + await cmd.ExecuteNonQueryAsync(); + } + // ========== READ METHODS ========== public async Task<(string? CvUrl, string? Skills, string? TechnicalSkills, string? Education, string? University, string? Degree)> @@ -259,6 +369,7 @@ INSERT INTO dbo.cv_analysis_results await cmd.ExecuteNonQueryAsync(); } + // ========== UPDATED METHOD - Add retry logic ========== public async Task SaveJobMatchAnalyticsAsync(JobMatchAnalytics match) { const string sql = @" @@ -266,17 +377,38 @@ INSERT INTO dbo.job_match_analytics (job_id, student_id, match_score, matched_skills, missing_skills, partial_matches, recommendation) VALUES (@jobId, @studentId, @matchScore, @matchedSkills, @missingSkills, @partialMatches, @recommendation)"; - using var conn = _db.CreateConnection(); - await conn.OpenAsync(); - using var cmd = new SqlCommand(sql, conn); - cmd.Parameters.AddWithValue("@jobId", match.JobId); - cmd.Parameters.AddWithValue("@studentId", match.StudentId); - cmd.Parameters.AddWithValue("@matchScore", match.MatchScore); - cmd.Parameters.AddWithValue("@matchedSkills", (object?)match.MatchedSkills ?? DBNull.Value); - cmd.Parameters.AddWithValue("@missingSkills", (object?)match.MissingSkills ?? DBNull.Value); - cmd.Parameters.AddWithValue("@partialMatches", (object?)match.PartialMatches ?? DBNull.Value); - cmd.Parameters.AddWithValue("@recommendation", (object?)match.Recommendation ?? DBNull.Value); - await cmd.ExecuteNonQueryAsync(); + try + { + using var conn = _db.CreateConnection(); + await conn.OpenAsync(); + using var cmd = new SqlCommand(sql, conn); + cmd.Parameters.AddWithValue("@jobId", match.JobId); + cmd.Parameters.AddWithValue("@studentId", match.StudentId); + cmd.Parameters.AddWithValue("@matchScore", match.MatchScore); + cmd.Parameters.AddWithValue("@matchedSkills", (object?)match.MatchedSkills ?? DBNull.Value); + cmd.Parameters.AddWithValue("@missingSkills", (object?)match.MissingSkills ?? DBNull.Value); + cmd.Parameters.AddWithValue("@partialMatches", (object?)match.PartialMatches ?? DBNull.Value); + cmd.Parameters.AddWithValue("@recommendation", (object?)match.Recommendation ?? DBNull.Value); + await cmd.ExecuteNonQueryAsync(); + } + catch (SqlException ex) when (ex.Number == 208) // Table missing error + { + // Try to create tables and retry once + await EnsureAiTablesExistAsync(); + + // Retry the insert + using var conn = _db.CreateConnection(); + await conn.OpenAsync(); + using var cmd = new SqlCommand(sql, conn); + cmd.Parameters.AddWithValue("@jobId", match.JobId); + cmd.Parameters.AddWithValue("@studentId", match.StudentId); + cmd.Parameters.AddWithValue("@matchScore", match.MatchScore); + cmd.Parameters.AddWithValue("@matchedSkills", (object?)match.MatchedSkills ?? DBNull.Value); + cmd.Parameters.AddWithValue("@missingSkills", (object?)match.MissingSkills ?? DBNull.Value); + cmd.Parameters.AddWithValue("@partialMatches", (object?)match.PartialMatches ?? DBNull.Value); + cmd.Parameters.AddWithValue("@recommendation", (object?)match.Recommendation ?? DBNull.Value); + await cmd.ExecuteNonQueryAsync(); + } } public async Task SaveApplicantScreeningAsync(int applicationId, int score, string recommendation, string aiAnalysisJson) @@ -556,4 +688,4 @@ public class CompanyActivity public int JobCount { get; set; } public int TotalApplications { get; set; } } -} \ No newline at end of file +} diff --git a/Services/JobMatchingService.cs b/Services/JobMatchingService.cs index 00e0636..f2699e2 100644 --- a/Services/JobMatchingService.cs +++ b/Services/JobMatchingService.cs @@ -22,9 +22,11 @@ public async Task CalculateMatchAsync( string cvText, string studentSkills, string studentEducation, int studentId, int jobId, string jobTitle, string jobDescription, string jobRequirements, string companyName) { - var systemInstruction = @"You are a job matching algorithm expert. Return ONLY valid JSON."; + try + { + var systemInstruction = @"You are a job matching algorithm expert. Return ONLY valid JSON."; - var prompt = $@" + var prompt = $@" Calculate the match percentage between this candidate and job. CANDIDATE PROFILE: @@ -47,8 +49,6 @@ Calculate the match percentage between this candidate and job. ""recommendation"": ""string"" }}"; - try - { var result = await _gemini.GenerateStructuredContentAsync(prompt, systemInstruction); var response = new JobMatchResponse @@ -65,24 +65,45 @@ Calculate the match percentage between this candidate and job. CalculatedAt = DateTime.UtcNow }; - // Save to database - await _aiRepo.SaveJobMatchAnalyticsAsync(new JobMatchAnalytics + // Save to database (non-critical - don't fail if save fails) + try { - JobId = jobId, - StudentId = studentId, - MatchScore = response.MatchPercentage, - MatchedSkills = string.Join("|", response.MatchedSkills), - MissingSkills = string.Join("|", response.MissingSkills), - PartialMatches = string.Join("|", response.PartialMatches), - Recommendation = response.Recommendation - }); + await _aiRepo.SaveJobMatchAnalyticsAsync(new JobMatchAnalytics + { + JobId = jobId, + StudentId = studentId, + MatchScore = response.MatchPercentage, + MatchedSkills = string.Join("|", response.MatchedSkills), + MissingSkills = string.Join("|", response.MissingSkills), + PartialMatches = string.Join("|", response.PartialMatches), + Recommendation = response.Recommendation + }); + } + catch (Exception dbEx) + { + _logger.LogWarning(dbEx, "Failed to save match analytics, but continuing"); + } return response; } catch (Exception ex) { _logger.LogError(ex, "Failed to calculate match for student {StudentId}, job {JobId}", studentId, jobId); - throw; + + // Return fallback data instead of throwing + return new JobMatchResponse + { + StudentId = studentId, + JobId = jobId, + JobTitle = jobTitle, + CompanyName = companyName, + MatchPercentage = 50, + MatchedSkills = new List(), + MissingSkills = new List(), + PartialMatches = new List(), + Recommendation = "AI service is currently busy. Please try again in a few moments.", + CalculatedAt = DateTime.UtcNow + }; } } diff --git a/appsettings.json b/appsettings.json index d370dfe..14e3e47 100644 --- a/appsettings.json +++ b/appsettings.json @@ -34,7 +34,7 @@ }, "Gemini": { "ApiKey": "", - "Model": "gemini-2.5-flash" + "Model": "gemini-1.5-flash" }, "AllowedHosts": "*" } \ No newline at end of file