From 54fce8692deb214ffe7237e9ba7b0a168d403d73 Mon Sep 17 00:00:00 2001 From: it23596566 Date: Thu, 16 Apr 2026 05:26:54 +0530 Subject: [PATCH 1/2] add cv text extraction --- Controllers/AIDashboardController.cs | 214 ++++++++++----- PathFinderBackend.csproj | 2 + Program.cs | 1 + Repositories/AiAnalyticsRepository.cs | 141 +++++++--- Services/CachingService.cs | 55 +++- Services/CvTextExtractorService.cs | 359 ++++++++++++++++++++++++++ Services/GeminiAIService.cs | 2 +- 7 files changed, 666 insertions(+), 108 deletions(-) create mode 100644 Services/CvTextExtractorService.cs diff --git a/Controllers/AIDashboardController.cs b/Controllers/AIDashboardController.cs index 8284cbb..f6cf3bc 100644 --- a/Controllers/AIDashboardController.cs +++ b/Controllers/AIDashboardController.cs @@ -16,19 +16,22 @@ public class AIDashboardController : ControllerBase private readonly JobMatchingService _matchingService; private readonly ILogger _logger; private readonly IWebHostEnvironment _env; + private readonly CvTextExtractorService _cvExtractor; public AIDashboardController( AiAnalyticsRepository aiRepo, AtsScoringService atsService, JobMatchingService matchingService, ILogger logger, - IWebHostEnvironment env) + IWebHostEnvironment env, + CvTextExtractorService cvExtractor) { _aiRepo = aiRepo; _atsService = atsService; _matchingService = matchingService; _logger = logger; _env = env; + _cvExtractor = cvExtractor; } [HttpPost("student/ats/analyze")] @@ -38,7 +41,8 @@ public async Task AnalyzeMyCv([FromBody] AtsAnalysisRequest? requ try { var studentId = GetCurrentUserId(); - if (studentId == null) return Unauthorized(new { message = "Invalid token" }); + if (studentId == null) + return Unauthorized(new { message = "Invalid token" }); var (cvUrl, skills, technicalSkills, education, university, degree) = await _aiRepo.GetStudentProfileForAnalysisAsync(studentId.Value); @@ -46,20 +50,30 @@ public async Task AnalyzeMyCv([FromBody] AtsAnalysisRequest? requ if (string.IsNullOrEmpty(cvUrl)) return BadRequest(new { message = "Please upload your CV first.", code = "no_cv" }); - var cvText = await ExtractTextFromCvAsync(cvUrl); - if (string.IsNullOrWhiteSpace(cvText)) - return BadRequest(new { message = "Could not extract text from CV.", code = "cv_parsing_error" }); + string cvText; + try + { + cvText = await _cvExtractor.ExtractTextFromCvAsync(cvUrl); + if (string.IsNullOrWhiteSpace(cvText)) + return BadRequest(new { message = "Could not extract text from CV. Please ensure it's a valid PDF or DOCX file.", code = "cv_parsing_error" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to extract text from CV for student {StudentId}", studentId); + return StatusCode(500, new { message = "Failed to process CV file. Please try uploading again.", code = "cv_processing_error" }); + } AtsScoreResponse result; if (request != null && request.JobId > 0) { var job = await _aiRepo.GetJobByIdAsync(request.JobId); - if (job == null) return NotFound(new { message = "Job not found" }); + if (job == null) + return NotFound(new { message = "Job not found" }); - // FIXED: Removed the extra jobId parameter (5 arguments only) result = await _atsService.AnalyzeCvAgainstJobAsync( - cvText, job.Value.Item2, job.Value.Item3, job.Value.Item4, studentId.Value); + cvText, job.Title, job.Description, job.Requirements, studentId.Value); + result.JobId = request.JobId; } else { @@ -82,7 +96,8 @@ public async Task GetJobMatches([FromQuery] int? limit = null) try { var studentId = GetCurrentUserId(); - if (studentId == null) return Unauthorized(new { message = "Invalid token" }); + if (studentId == null) + return Unauthorized(new { message = "Invalid token" }); var (cvUrl, skills, technicalSkills, education, university, degree) = await _aiRepo.GetStudentProfileForAnalysisAsync(studentId.Value); @@ -90,7 +105,19 @@ public async Task GetJobMatches([FromQuery] int? limit = null) if (string.IsNullOrEmpty(cvUrl)) return BadRequest(new { message = "Please upload your CV first.", code = "no_cv" }); - var cvText = await ExtractTextFromCvAsync(cvUrl); + string cvText; + try + { + cvText = await _cvExtractor.ExtractTextFromCvAsync(cvUrl); + if (string.IsNullOrWhiteSpace(cvText)) + return BadRequest(new { message = "Could not extract text from CV. Please ensure it's a valid PDF or DOCX file.", code = "cv_parsing_error" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to extract text from CV for student {StudentId}", studentId); + return StatusCode(500, new { message = "Failed to process CV file. Please try uploading again.", code = "cv_processing_error" }); + } + var allSkills = $"{skills ?? ""} {technicalSkills ?? ""}"; var allEducation = $"{education ?? ""} {university ?? ""} {degree ?? ""}"; @@ -99,25 +126,14 @@ public async Task GetJobMatches([FromQuery] int? limit = null) return Ok(new BatchJobMatchesResponse { Matches = new(), TotalJobsAnalyzed = 0 }); var jobsToAnalyze = limit.HasValue ? jobs.Take(limit.Value).ToList() : jobs; - var matches = new List(); + + var matches = await ProcessJobsInParallel(cvText, allSkills, allEducation, studentId.Value, jobsToAnalyze); - foreach (var job in jobsToAnalyze) - { - try - { - var match = await _matchingService.CalculateMatchAsync( - cvText, allSkills, allEducation, studentId.Value, - job.Id, job.Title, job.Description, job.Requirements, job.CompanyName); - matches.Add(match); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to match job {JobId}", job.Id); - } - } - - matches = matches.OrderByDescending(m => m.MatchPercentage).ToList(); - return Ok(new BatchJobMatchesResponse { Matches = matches, TotalJobsAnalyzed = matches.Count }); + return Ok(new BatchJobMatchesResponse + { + Matches = matches.OrderByDescending(m => m.MatchPercentage).ToList(), + TotalJobsAnalyzed = matches.Count + }); } catch (Exception ex) { @@ -133,10 +149,12 @@ public async Task GetJobMatchForSpecificJob(int jobId) try { var studentId = GetCurrentUserId(); - if (studentId == null) return Unauthorized(new { message = "Invalid token" }); + if (studentId == null) + return Unauthorized(new { message = "Invalid token" }); var job = await _aiRepo.GetJobByIdAsync(jobId); - if (job == null) return NotFound(new { message = "Job not found" }); + if (job == null) + return NotFound(new { message = "Job not found" }); var (cvUrl, skills, technicalSkills, education, university, degree) = await _aiRepo.GetStudentProfileForAnalysisAsync(studentId.Value); @@ -144,13 +162,25 @@ public async Task GetJobMatchForSpecificJob(int jobId) if (string.IsNullOrEmpty(cvUrl)) return BadRequest(new { message = "Please upload your CV first.", code = "no_cv" }); - var cvText = await ExtractTextFromCvAsync(cvUrl); + string cvText; + try + { + cvText = await _cvExtractor.ExtractTextFromCvAsync(cvUrl); + if (string.IsNullOrWhiteSpace(cvText)) + return BadRequest(new { message = "Could not extract text from CV. Please ensure it's a valid PDF or DOCX file.", code = "cv_parsing_error" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to extract text from CV for student {StudentId}", studentId); + return StatusCode(500, new { message = "Failed to process CV file. Please try uploading again.", code = "cv_processing_error" }); + } + var allSkills = $"{skills ?? ""} {technicalSkills ?? ""}"; var allEducation = $"{education ?? ""} {university ?? ""} {degree ?? ""}"; var match = await _matchingService.CalculateMatchAsync( cvText, allSkills, allEducation, studentId.Value, - job.Value.Item1, job.Value.Item2, job.Value.Item3, job.Value.Item4, job.Value.Item5); + job.Id, job.Title, job.Description, job.Requirements, job.CompanyName); return Ok(match); } @@ -168,41 +198,64 @@ public async Task GetRankedApplicants(int jobId) try { var companyId = GetCurrentCompanyId(); - if (companyId == null) return Unauthorized(new { message = "Invalid token" }); + if (companyId == null) + return Unauthorized(new { message = "Invalid token" }); var job = await _aiRepo.GetJobByIdAsync(jobId); - if (job == null || job.Value.Item6 != companyId.Value) + if (job == null || job.CompanyId != companyId.Value) return NotFound(new { message = "Job not found or access denied" }); var applicants = await _aiRepo.GetApplicantsForJobAsync(jobId, companyId.Value); if (applicants.Count == 0) - return Ok(new RankedApplicantsResponse { JobId = jobId, JobTitle = job.Value.Item2, Applicants = new() }); + return Ok(new RankedApplicantsResponse { JobId = jobId, JobTitle = job.Title, Applicants = new() }); var rankedApplicants = new List(); - foreach (var applicant in applicants) + + var semaphore = new SemaphoreSlim(3); + var tasks = applicants.Select(async applicant => { - var mockCvText = $"Student {applicant.StudentName} with skills: {applicant.Skills ?? "Not specified"}"; - var match = await _matchingService.CalculateMatchAsync( - mockCvText, applicant.Skills ?? "", "", applicant.StudentId, - jobId, job.Value.Item2, job.Value.Item3, job.Value.Item4, job.Value.Item5); + await semaphore.WaitAsync(); + try + { + string cvText; + if (!string.IsNullOrEmpty(applicant.CvUrl)) + { + cvText = await _cvExtractor.ExtractTextFromCvAsync(applicant.CvUrl); + } + else + { + cvText = $"Student {applicant.StudentName} has not uploaded a CV."; + } + + var match = await _matchingService.CalculateMatchAsync( + cvText, applicant.Skills ?? "", "", applicant.StudentId, + jobId, job.Title, job.Description, job.Requirements, job.CompanyName); - rankedApplicants.Add(new RankedApplicantResponse + return new RankedApplicantResponse + { + ApplicationId = applicant.ApplicationId, + StudentId = applicant.StudentId, + StudentName = applicant.StudentName, + StudentEmail = applicant.StudentEmail, + Rank = 0, + AtsScore = match.MatchPercentage, + MatchScore = match.MatchPercentage, + Reasoning = match.Recommendation, + TopSkills = match.MatchedSkills.Take(5).ToList(), + MissingRequirements = match.MissingSkills.Take(5).ToList(), + CvUrl = applicant.CvUrl, + ApplicationStatus = applicant.Status, + AppliedDate = applicant.AppliedDate + }; + } + finally { - ApplicationId = applicant.ApplicationId, - StudentId = applicant.StudentId, - StudentName = applicant.StudentName, - StudentEmail = applicant.StudentEmail, - Rank = 0, - AtsScore = match.MatchPercentage, - MatchScore = match.MatchPercentage, - Reasoning = match.Recommendation, - TopSkills = match.MatchedSkills.Take(5).ToList(), - MissingRequirements = match.MissingSkills.Take(5).ToList(), - CvUrl = applicant.CvUrl, - ApplicationStatus = applicant.Status, - AppliedDate = applicant.AppliedDate - }); - } + semaphore.Release(); + } + }); + + var results = await Task.WhenAll(tasks); + rankedApplicants = results.ToList(); rankedApplicants = rankedApplicants.OrderByDescending(a => a.MatchScore) .Select((a, index) => { a.Rank = index + 1; return a; }).ToList(); @@ -212,7 +265,7 @@ public async Task GetRankedApplicants(int jobId) return Ok(new RankedApplicantsResponse { JobId = jobId, - JobTitle = job.Value.Item2, + JobTitle = job.Title, Applicants = rankedApplicants, TotalApplicants = rankedApplicants.Count, AverageScore = averageScore @@ -232,10 +285,17 @@ public async Task GetAdminInsights() try { var stats = await _aiRepo.GetPlatformStatsAsync(); - var skillDistribution = await _aiRepo.GetJobSkillDistributionAsync(); + var skillDistribution = await _aiRepo.GetJobSkillDistributionFromDatabaseAsync(); var topSkills = skillDistribution.Take(10) - .Select(kv => new SkillTrend { SkillName = kv.Key, JobPostingsCount = kv.Value }) + .Select(kv => new SkillTrend + { + SkillName = kv.Key, + JobPostingsCount = kv.Value, + StudentsWithSkill = 0, + GapCount = 0, + GrowthRate = 0 + }) .ToList(); var insights = new AdminAiInsightsResponse @@ -284,6 +344,35 @@ public async Task GetAdminInsights() } } + #region Private Helper Methods + + private async Task> ProcessJobsInParallel( + string cvText, + string allSkills, + string allEducation, + int studentId, + List jobs) + { + var semaphore = new SemaphoreSlim(5); + var tasks = jobs.Select(async job => + { + await semaphore.WaitAsync(); + try + { + return await _matchingService.CalculateMatchAsync( + cvText, allSkills, allEducation, studentId, + job.Id, job.Title, job.Description, job.Requirements, job.CompanyName); + } + finally + { + semaphore.Release(); + } + }); + + var results = await Task.WhenAll(tasks); + return results.ToList(); + } + private int? GetCurrentUserId() { var userIdStr = User.FindFirst("userId")?.Value ?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value; @@ -296,11 +385,6 @@ public async Task GetAdminInsights() return int.TryParse(userIdStr, out var id) ? id : null; } - private async Task ExtractTextFromCvAsync(string cvUrl) - { - _logger.LogWarning("CV text extraction simplified - using placeholder"); - await Task.Delay(10); - return "Sample CV content with skills: C#, JavaScript, SQL, Teamwork, Leadership. Education: Bachelor's Degree in Computer Science."; - } + #endregion } } \ No newline at end of file diff --git a/PathFinderBackend.csproj b/PathFinderBackend.csproj index 5230c7a..299f8fb 100644 --- a/PathFinderBackend.csproj +++ b/PathFinderBackend.csproj @@ -31,6 +31,8 @@ + + diff --git a/Program.cs b/Program.cs index 2e3bda5..87d9743 100644 --- a/Program.cs +++ b/Program.cs @@ -34,6 +34,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); //AI Services builder.Services.AddMemoryCache(); diff --git a/Repositories/AiAnalyticsRepository.cs b/Repositories/AiAnalyticsRepository.cs index 2838987..28b5f5d 100644 --- a/Repositories/AiAnalyticsRepository.cs +++ b/Repositories/AiAnalyticsRepository.cs @@ -45,10 +45,10 @@ FROM dbo.students s return (null, null, null, null, null, null); } - public async Task> GetActiveJobsAsync() + public async Task> GetActiveJobsAsync() { const string sql = @" - SELECT j.id, j.title, j.description, COALESCE(j.requirements, ''), c.company_name + SELECT j.id, j.title, j.description, COALESCE(j.requirements, ''), c.company_name, c.id as company_id FROM dbo.jobs j INNER JOIN dbo.companies c ON j.company_id = c.id WHERE (j.is_deleted IS NULL OR j.is_deleted = 0) @@ -56,7 +56,7 @@ FROM dbo.jobs j AND c.status = 'APPROVED' ORDER BY j.created_at DESC"; - var jobs = new List<(int, string, string, string, string)>(); + var jobs = new List(); using var conn = _db.CreateConnection(); await conn.OpenAsync(); using var cmd = new SqlCommand(sql, conn); @@ -64,12 +64,20 @@ FROM dbo.jobs j while (await reader.ReadAsync()) { - jobs.Add((reader.GetInt32(0), reader.GetString(1), reader.GetString(2), reader.GetString(3), reader.GetString(4))); + jobs.Add(new JobInfo + { + Id = reader.GetInt32(0), + Title = reader.GetString(1), + Description = reader.GetString(2), + Requirements = reader.GetString(3), + CompanyName = reader.GetString(4), + CompanyId = reader.GetInt32(5) + }); } return jobs; } - public async Task<(int Id, string Title, string Description, string Requirements, string CompanyName, int CompanyId)?> GetJobByIdAsync(int jobId) + public async Task GetJobByIdAsync(int jobId) { const string sql = @" SELECT j.id, j.title, j.description, COALESCE(j.requirements, ''), c.company_name, c.id @@ -85,13 +93,20 @@ FROM dbo.jobs j if (await reader.ReadAsync()) { - return (reader.GetInt32(0), reader.GetString(1), reader.GetString(2), reader.GetString(3), reader.GetString(4), reader.GetInt32(5)); + return new JobInfo + { + Id = reader.GetInt32(0), + Title = reader.GetString(1), + Description = reader.GetString(2), + Requirements = reader.GetString(3), + CompanyName = reader.GetString(4), + CompanyId = reader.GetInt32(5) + }; } return null; } - public async Task> - GetApplicantsForJobAsync(int jobId, int companyId) + public async Task> GetApplicantsForJobAsync(int jobId, int companyId) { const string sql = @" SELECT a.id, s.id, s.full_name, s.email, sp.cv_url, sp.skills, a.status, a.applied_date @@ -103,7 +118,7 @@ FROM dbo.applications a AND (s.is_deleted IS NULL OR s.is_deleted = 0) ORDER BY a.applied_date DESC"; - var applicants = new List<(int, int, string, string, string?, string?, string, DateTime)>(); + var applicants = new List(); using var conn = _db.CreateConnection(); await conn.OpenAsync(); using var cmd = new SqlCommand(sql, conn); @@ -113,16 +128,69 @@ FROM dbo.applications a while (await reader.ReadAsync()) { - applicants.Add(( - reader.GetInt32(0), reader.GetInt32(1), reader.GetString(2), reader.GetString(3), - reader.IsDBNull(4) ? null : reader.GetString(4), - reader.IsDBNull(5) ? null : reader.GetString(5), - reader.GetString(6), reader.GetDateTime(7) - )); + applicants.Add(new ApplicantInfo + { + ApplicationId = reader.GetInt32(0), + StudentId = reader.GetInt32(1), + StudentName = reader.GetString(2), + StudentEmail = reader.GetString(3), + CvUrl = reader.IsDBNull(4) ? null : reader.GetString(4), + Skills = reader.IsDBNull(5) ? null : reader.GetString(5), + Status = reader.GetString(6), + AppliedDate = reader.GetDateTime(7) + }); } return applicants; } + /// + /// Get REAL skill distribution from job requirements (not hardcoded) + /// + public async Task> GetJobSkillDistributionFromDatabaseAsync() + { + var skills = new Dictionary(StringComparer.OrdinalIgnoreCase); + + const string sql = @" + SELECT requirements + FROM dbo.jobs + WHERE (is_deleted IS NULL OR is_deleted = 0) + AND requirements IS NOT NULL"; + + using var conn = _db.CreateConnection(); + await conn.OpenAsync(); + using var cmd = new SqlCommand(sql, conn); + using var reader = await cmd.ExecuteReaderAsync(); + + var commonSkills = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "C#", "JavaScript", "Python", "Java", "SQL", "TypeScript", "React", + "Angular", "Vue", "Node.js", ".NET", "Spring Boot", "Django", "Flask", + "AWS", "Azure", "Docker", "Kubernetes", "Git", "REST API", "GraphQL", + "MongoDB", "PostgreSQL", "MySQL", "Redis", "Entity Framework", "LINQ", + "HTML", "CSS", "Bootstrap", "Tailwind", "jQuery", "PHP", "Ruby", "Go", + "Rust", "Swift", "Kotlin", "Flutter", "React Native", "Xamarin" + }; + + while (await reader.ReadAsync()) + { + var requirements = reader.GetString(0); + if (string.IsNullOrEmpty(requirements)) continue; + + foreach (var skill in commonSkills) + { + if (requirements.Contains(skill, StringComparison.OrdinalIgnoreCase)) + { + if (skills.ContainsKey(skill)) + skills[skill]++; + else + skills[skill] = 1; + } + } + } + + return skills.OrderByDescending(x => x.Value).ToDictionary(x => x.Key, x => x.Value); + } + public async Task GetPlatformStatsAsync() { var stats = new PlatformStats(); @@ -174,25 +242,6 @@ SELECT COUNT(1) AS ApplicationCount FROM dbo.applications GROUP BY job_id return stats; } - // FIXED: Removed async keyword - returns Task directly - public Task> GetJobSkillDistributionAsync() - { - var skills = new Dictionary - { - { "C#", 45 }, - { "JavaScript", 38 }, - { "Python", 32 }, - { "SQL", 28 }, - { "Java", 25 }, - { "React", 22 }, - { "Azure", 18 }, - { "Docker", 15 }, - { "TypeScript", 14 }, - { "AWS", 12 } - }; - return Task.FromResult(skills); - } - // ========== WRITE METHODS (Storage) ========== public async Task SaveCvAnalysisResultAsync(CvAnalysisResult result) @@ -257,6 +306,8 @@ INSERT INTO dbo.applicant_screening (application_id, screening_score, screening_ await cmd.ExecuteNonQueryAsync(); } + // ========== DTO Classes ========== + public class PlatformStats { public int TotalStudents { get; set; } @@ -270,4 +321,26 @@ public class PlatformStats public double ApplicationSuccessRate { get; set; } } } + + public class JobInfo + { + public int Id { get; set; } + public string Title { get; set; } = ""; + public string Description { get; set; } = ""; + public string Requirements { get; set; } = ""; + public string CompanyName { get; set; } = ""; + public int CompanyId { get; set; } + } + + public class ApplicantInfo + { + public int ApplicationId { get; set; } + public int StudentId { get; set; } + public string StudentName { get; set; } = ""; + public string StudentEmail { get; set; } = ""; + public string? CvUrl { get; set; } + public string? Skills { get; set; } + public string Status { get; set; } = ""; + public DateTime AppliedDate { get; set; } + } } \ No newline at end of file diff --git a/Services/CachingService.cs b/Services/CachingService.cs index 3566468..d8a957d 100644 --- a/Services/CachingService.cs +++ b/Services/CachingService.cs @@ -3,7 +3,16 @@ namespace PATHFINDER_BACKEND.Services { - public class CachingService + public interface ICachingService + { + Task GetAsync(string key); + Task SetAsync(string key, T value, TimeSpan? expiry = null); + Task RemoveAsync(string key); + bool TryGet(string key, out T? value); + void Set(string key, T value, TimeSpan? expiry = null); + } + + public class CachingService : ICachingService { private readonly IMemoryCache _cache; private readonly ILogger _logger; @@ -14,26 +23,56 @@ public CachingService(IMemoryCache cache, ILogger logger) _logger = logger; } - public async Task GetOrCreateAsync(string key, Func> factory, TimeSpan? expiry = null) + public async Task GetAsync(string key) { - if (_cache.TryGetValue(key, out T? cachedValue) && cachedValue != null) + return await Task.Run(() => Get(key)); + } + + public T? Get(string key) + { + if (_cache.TryGetValue(key, out T? cachedValue)) { _logger.LogDebug("Cache hit for key: {Key}", key); return cachedValue; } + + _logger.LogDebug("Cache miss for key: {Key}", key); + return default; + } + + public bool TryGet(string key, out T? value) + { + var result = _cache.TryGetValue(key, out value); + if (result) + _logger.LogDebug("Cache hit for key: {Key}", key); + else + _logger.LogDebug("Cache miss for key: {Key}", key); + return result; + } - _logger.LogDebug("Cache miss for key: {Key}, computing value", key); - var value = await factory(); + public async Task SetAsync(string key, T value, TimeSpan? expiry = null) + { + await Task.Run(() => Set(key, value, expiry)); + } + public void Set(string key, T value, TimeSpan? expiry = null) + { var options = new MemoryCacheEntryOptions() .SetSlidingExpiration(expiry ?? TimeSpan.FromHours(24)) .SetPriority(CacheItemPriority.Normal); - + _cache.Set(key, value, options); - return value; + _logger.LogDebug("Cached value for key: {Key}", key); + } + + public async Task RemoveAsync(string key) + { + await Task.Run(() => _cache.Remove(key)); + _logger.LogDebug("Removed cache for key: {Key}", key); } public static string MatchCacheKey(int studentId, int jobId) => $"match_{studentId}_{jobId}"; - public static string AtsCacheKey(int studentId) => $"ats_{studentId}"; + public static string AtsCacheKey(int studentId, int? jobId = null) => + jobId.HasValue ? $"ats_{studentId}_job_{jobId}" : $"ats_{studentId}_standalone"; } } \ No newline at end of file diff --git a/Services/CvTextExtractorService.cs b/Services/CvTextExtractorService.cs new file mode 100644 index 0000000..4cba734 --- /dev/null +++ b/Services/CvTextExtractorService.cs @@ -0,0 +1,359 @@ +using Azure.Storage.Blobs; +using iText.Kernel.Pdf; +using iText.Kernel.Pdf.Canvas.Parser; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; +using System.Text.RegularExpressions; + +namespace PATHFINDER_BACKEND.Services +{ + /// + /// Service for extracting text from CV files (PDF, DOCX) stored in Azure Blob Storage + /// + public class CvTextExtractorService + { + private readonly ILogger _logger; + private readonly IWebHostEnvironment _env; + private readonly string _blobConnectionString; + private readonly string _blobContainerName; + private readonly string _tempDirectory; + private readonly bool _useBlobStorage; + + public CvTextExtractorService( + IConfiguration configuration, + ILogger logger, + IWebHostEnvironment env) + { + _logger = logger; + _env = env; + + // Setup temp directory for downloading blobs + _tempDirectory = Path.Combine(Path.GetTempPath(), "PathFinder_CV_Extracts"); + if (!Directory.Exists(_tempDirectory)) + { + Directory.CreateDirectory(_tempDirectory); + } + + // Check if using Azure Blob Storage or local storage + _blobConnectionString = configuration["AzureBlobStorage:ConnectionString"] ?? ""; + _blobContainerName = configuration["AzureBlobStorage:ContainerName"] ?? "student-cvs"; + _useBlobStorage = !string.IsNullOrEmpty(_blobConnectionString); + + _logger.LogInformation($"CvTextExtractorService initialized. Using Blob Storage: {_useBlobStorage}"); + } + + /// + /// Extract text from CV file (supports PDF, DOCX) + /// + /// URL of the CV file (Azure Blob URL or local path) + /// Extracted text content + public async Task ExtractTextFromCvAsync(string fileUrl) + { + if (string.IsNullOrWhiteSpace(fileUrl)) + { + _logger.LogWarning("Empty CV URL provided"); + return ""; + } + + string? localFilePath = null; + + try + { + // Download file from blob storage or get local path + localFilePath = await DownloadFileToLocalAsync(fileUrl); + + if (string.IsNullOrEmpty(localFilePath) || !File.Exists(localFilePath)) + { + _logger.LogError($"File not found after download: {localFilePath}"); + return ""; + } + + // Extract text based on file extension + var extension = Path.GetExtension(localFilePath).ToLowerInvariant(); + string extractedText = extension switch + { + ".pdf" => ExtractTextFromPdf(localFilePath), + ".docx" => ExtractTextFromDocx(localFilePath), + ".doc" => await ExtractTextFromDocAsync(localFilePath), + _ => throw new NotSupportedException($"Unsupported file type: {extension}. Supported types: PDF, DOCX, DOC") + }; + + // Clean and normalize extracted text + extractedText = CleanExtractedText(extractedText); + + _logger.LogInformation($"Successfully extracted {extractedText.Length} characters from CV: {Path.GetFileName(fileUrl)}"); + + return extractedText; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to extract text from CV: {fileUrl}"); + throw; + } + finally + { + // Clean up temporary file + if (!string.IsNullOrEmpty(localFilePath) && File.Exists(localFilePath)) + { + try + { + File.Delete(localFilePath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"Failed to delete temp file: {localFilePath}"); + } + } + } + } + + /// + /// Extract text from PDF using iText7 + /// + private string ExtractTextFromPdf(string filePath) + { + var text = new System.Text.StringBuilder(); + + try + { + using (var pdfReader = new PdfReader(filePath)) + using (var pdfDoc = new PdfDocument(pdfReader)) + { + int pageCount = pdfDoc.GetNumberOfPages(); + for (int page = 1; page <= pageCount; page++) + { + var pageText = PdfTextExtractor.GetTextFromPage(pdfDoc.GetPage(page)); + if (!string.IsNullOrEmpty(pageText)) + { + text.AppendLine(pageText); + } + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error extracting text from PDF: {filePath}"); + throw; + } + + return text.ToString(); + } + + /// + /// Extract text from DOCX using OpenXML + /// + private string ExtractTextFromDocx(string filePath) + { + var text = new System.Text.StringBuilder(); + + try + { + using (var wordDoc = WordprocessingDocument.Open(filePath, false)) + { + // Safe navigation with null checks + var mainPart = wordDoc.MainDocumentPart; + if (mainPart != null) + { + var document = mainPart.Document; + if (document != null) + { + var body = document.Body; + if (body != null) + { + foreach (var paragraph in body.Elements()) + { + foreach (var run in paragraph.Elements()) + { + foreach (var textElement in run.Elements()) + { + if (textElement.Text != null) + { + text.Append(textElement.Text); + } + } + } + text.AppendLine(); + } + } + } + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error extracting text from DOCX: {filePath}"); + throw; + } + + return text.ToString(); + } + + /// + /// Extract text from legacy DOC files using alternative method + /// + private async Task ExtractTextFromDocAsync(string filePath) + { + _logger.LogWarning("Legacy .doc files are not fully supported. Consider converting to .docx"); + + try + { + // Fallback: Try to read as text (may not work well) + return await File.ReadAllTextAsync(filePath); + } + catch (Exception ex) + { + _logger.LogError(ex, $"Error extracting text from DOC file: {filePath}"); + return ""; // Return empty string on failure + } + } + + /// + /// Download file from Azure Blob Storage or get local path + /// + private async Task DownloadFileToLocalAsync(string fileUrl) + { + // Check if it's a local file path (not a URL) + if (!fileUrl.StartsWith("http://") && !fileUrl.StartsWith("https://")) + { + // Local file path - resolve from wwwroot + var localPath = Path.Combine(_env.WebRootPath, fileUrl.TrimStart('/')); + if (File.Exists(localPath)) + return localPath; + + _logger.LogWarning($"Local file not found: {localPath}"); + return fileUrl; + } + + // Azure Blob Storage URL + var fileName = GenerateSafeFileName(fileUrl); + var tempFilePath = Path.Combine(_tempDirectory, fileName); + + if (_useBlobStorage) + { + try + { + // Validate connection string + if (string.IsNullOrEmpty(_blobConnectionString)) + { + throw new InvalidOperationException("Azure Blob Storage connection string is not configured."); + } + + // Parse blob URL to get blob name + var blobUri = new Uri(fileUrl); + var blobName = blobUri.Segments.LastOrDefault(); + + if (string.IsNullOrEmpty(blobName)) + { + blobName = fileName; + } + + var blobContainerClient = new BlobContainerClient(_blobConnectionString, _blobContainerName); + var blobClient = blobContainerClient.GetBlobClient(blobName); + + // Download blob to temp file + await blobClient.DownloadToAsync(tempFilePath); + _logger.LogInformation($"Downloaded blob to temp file: {tempFilePath}"); + + return tempFilePath; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to download blob from Azure: {fileUrl}"); + throw new Exception($"Failed to download CV from storage: {ex.Message}"); + } + } + else + { + // Fallback: Try to download via HTTP client + try + { + using var httpClient = new HttpClient(); + httpClient.Timeout = TimeSpan.FromSeconds(30); + + var response = await httpClient.GetAsync(fileUrl); + response.EnsureSuccessStatusCode(); + + using var fileStream = File.Create(tempFilePath); + await response.Content.CopyToAsync(fileStream); + + _logger.LogInformation($"Downloaded via HTTP to temp file: {tempFilePath}"); + return tempFilePath; + } + catch (Exception ex) + { + _logger.LogError(ex, $"Failed to download via HTTP: {fileUrl}"); + throw new Exception($"Failed to download CV via HTTP: {ex.Message}"); + } + } + } + + /// + /// Generate safe filename from URL + /// + private string GenerateSafeFileName(string url) + { + try + { + var uri = new Uri(url); + var fileName = Path.GetFileName(uri.LocalPath); + + if (string.IsNullOrEmpty(fileName)) + fileName = Guid.NewGuid().ToString(); + + // Remove any invalid characters from filename + fileName = Regex.Replace(fileName, @"[^a-zA-Z0-9_.-]", "_"); + + // Add timestamp to avoid collisions + var timestamp = DateTime.Now.Ticks; + var extension = Path.GetExtension(fileName); + var nameWithoutExt = Path.GetFileNameWithoutExtension(fileName); + + return $"{nameWithoutExt}_{timestamp}{extension}"; + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"Failed to parse URL, using GUID filename: {url}"); + return $"{Guid.NewGuid()}_{DateTime.Now.Ticks}.pdf"; + } + } + + /// + /// Clean extracted text by removing extra whitespace and normalizing + /// + private string CleanExtractedText(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return ""; + + try + { + // Replace multiple newlines with single newline + text = Regex.Replace(text, @"\r\n\s*\r\n", "\n\n"); + text = Regex.Replace(text, @"\n{3,}", "\n\n"); + + // Replace multiple spaces with single space + text = Regex.Replace(text, @"[ ]{2,}", " "); + + // Remove any null characters + text = text.Replace("\0", ""); + + // Trim + text = text.Trim(); + + // Limit length to reasonable size (20KB for AI processing) + if (text.Length > 20000) + { + text = text.Substring(0, 20000); + _logger.LogWarning($"CV text truncated to 20000 characters from original {text.Length}"); + } + + return text; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error cleaning extracted text"); + return text; // Return original on error + } + } + } +} \ No newline at end of file diff --git a/Services/GeminiAIService.cs b/Services/GeminiAIService.cs index 6ee27fe..12908fb 100644 --- a/Services/GeminiAIService.cs +++ b/Services/GeminiAIService.cs @@ -9,7 +9,7 @@ public class GeminiAIService { private readonly HttpClient _httpClient; private readonly string _apiKey; - private readonly string _model = "gemini-2.0-flash-exp"; + private readonly string _model = "gemini-2.5-flash"; private readonly ILogger _logger; public GeminiAIService(IConfiguration configuration, ILogger logger) From 74775f77093fa1309fec7a28dbfd49c81b8e4640 Mon Sep 17 00:00:00 2001 From: it23596566 Date: Thu, 16 Apr 2026 09:44:51 +0530 Subject: [PATCH 2/2] Implement AI-powered admin insights --- Controllers/AIDashboardController.cs | 299 +++++++++++++++++++++---- DTOs/AdminAiInsightsResponse.cs | 8 +- Program.cs | 1 + Repositories/AiAnalyticsRepository.cs | 239 ++++++++++++++++++-- Services/AiInsightsGeneratorService.cs | 299 +++++++++++++++++++++++++ Services/GeminiAIService.cs | 147 ++++++++++-- appsettings.json | 3 +- 7 files changed, 913 insertions(+), 83 deletions(-) create mode 100644 Services/AiInsightsGeneratorService.cs diff --git a/Controllers/AIDashboardController.cs b/Controllers/AIDashboardController.cs index f6cf3bc..5c3dcdc 100644 --- a/Controllers/AIDashboardController.cs +++ b/Controllers/AIDashboardController.cs @@ -17,6 +17,7 @@ public class AIDashboardController : ControllerBase private readonly ILogger _logger; private readonly IWebHostEnvironment _env; private readonly CvTextExtractorService _cvExtractor; + private readonly AiInsightsGeneratorService _aiInsightsGenerator; public AIDashboardController( AiAnalyticsRepository aiRepo, @@ -24,7 +25,8 @@ public AIDashboardController( JobMatchingService matchingService, ILogger logger, IWebHostEnvironment env, - CvTextExtractorService cvExtractor) + CvTextExtractorService cvExtractor, + AiInsightsGeneratorService aiInsightsGenerator) { _aiRepo = aiRepo; _atsService = atsService; @@ -32,6 +34,7 @@ public AIDashboardController( _logger = logger; _env = env; _cvExtractor = cvExtractor; + _aiInsightsGenerator = aiInsightsGenerator; } [HttpPost("student/ats/analyze")] @@ -280,67 +283,153 @@ public async Task GetRankedApplicants(int jobId) [HttpGet("admin/insights")] [Authorize(Roles = "ADMIN")] - public async Task GetAdminInsights() + public async Task GetAdminInsights([FromQuery] bool useAI = true) { try { - var stats = await _aiRepo.GetPlatformStatsAsync(); - var skillDistribution = await _aiRepo.GetJobSkillDistributionFromDatabaseAsync(); - - var topSkills = skillDistribution.Take(10) - .Select(kv => new SkillTrend - { - SkillName = kv.Key, - JobPostingsCount = kv.Value, - StudentsWithSkill = 0, - GapCount = 0, - GrowthRate = 0 - }) - .ToList(); - - var insights = new AdminAiInsightsResponse + var adminId = User.FindFirst("userId")?.Value; + if (string.IsNullOrEmpty(adminId)) { - TalentDemand = new TalentDemandInsights - { - MostSoughtAfterRole = topSkills.FirstOrDefault()?.SkillName ?? "Unknown", - FastestGrowingCategory = "Software Development", - AverageApplicantsPerJob = stats.AverageApplicantsPerJob, - TotalActiveJobs = stats.ActiveJobs, - TotalActiveStudents = stats.TotalStudents, - StudentToJobRatio = stats.ActiveJobs > 0 ? (double)stats.TotalStudents / stats.ActiveJobs : 0 - }, - PlatformHealth = new PlatformHealthInsights + return Unauthorized(new { message = "Invalid admin token" }); + } + + AdminAiInsightsResponse insights; + + if (useAI) + { + insights = await _aiInsightsGenerator.GenerateAiInsightsAsync(); + return Ok(new { - MonthOverMonthGrowth = stats.NewStudentsLast30Days > 0 ? - (stats.NewStudentsLast30Days / (double)Math.Max(1, stats.TotalStudents - stats.NewStudentsLast30Days)) * 100 : 0, - ApplicationSuccessRate = stats.ApplicationSuccessRate, - CompaniesNeedingAttention = stats.StuckPendingCompanies, - Recommendations = new List + success = true, + message = "AI-powered admin insights retrieved successfully", + data = insights, + metadata = new { - stats.StuckPendingCompanies > 0 ? $"Review {stats.StuckPendingCompanies} pending companies." : "All companies processed.", - stats.AverageApplicantsPerJob < 5 ? "Consider promoting jobs." : "Good applicant volume." + generatedBy = "Gemini AI", + generatedAt = DateTime.UtcNow, + version = "2.0", + aiEnabled = true } - }, - TopInDemandSkills = topSkills, - IndustryTrends = new(), - Predictions = new List + }); + } + else + { + insights = await GetBasicInsightsAsync(); + return Ok(new { - new Prediction + success = true, + message = "Basic admin insights retrieved (AI disabled)", + data = insights, + metadata = new { - Metric = "Job Growth", - PredictionText = $"Job postings expected to increase by {Math.Min(25, stats.NewStudentsLast30Days / 10)}% next month.", - ConfidenceScore = 75, - Timeframe = "30 days" + generatedBy = "Basic Analytics", + generatedAt = DateTime.UtcNow, + version = "2.0", + aiEnabled = false } + }); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting admin insights"); + + var fallbackInsights = await GetBasicInsightsAsync(); + return Ok(new + { + success = false, + message = "AI service temporarily unavailable. Showing basic insights.", + data = fallbackInsights, + error = _env.IsDevelopment() ? ex.Message : null, + metadata = new + { + generatedBy = "Fallback Analytics", + generatedAt = DateTime.UtcNow, + version = "2.0", + aiEnabled = false, + errorOccurred = true } - }; + }); + } + } - return Ok(insights); + [HttpGet("admin/insights/health")] + [Authorize(Roles = "ADMIN")] + public async Task GetPlatformHealth() + { + try + { + var stats = await _aiRepo.GetPlatformStatsAsync(); + var data = await _aiRepo.GetPlatformAnalyticsForAIAsync(); + + return Ok(new + { + success = true, + message = "Platform health metrics retrieved", + data = new + { + stats.TotalStudents, + stats.ActiveJobs, + stats.AverageApplicantsPerJob, + stats.ApplicationSuccessRate, + stats.NewStudentsLast30Days, + stats.StuckPendingCompanies, + StudentEngagementRate = data.StudentEngagementRate, + HealthScore = CalculateHealthScore(stats, data), + GeneratedAt = DateTime.UtcNow + } + }); } catch (Exception ex) { - _logger.LogError(ex, "Error getting admin insights"); - return StatusCode(503, new { message = "Service temporarily unavailable" }); + _logger.LogError(ex, "Error getting platform health"); + return StatusCode(500, new { success = false, message = "Failed to retrieve platform health" }); + } + } + + [HttpGet("admin/insights/skills-gap")] + [Authorize(Roles = "ADMIN")] + public async Task GetSkillsGapAnalysis() + { + try + { + var jobSkills = await _aiRepo.GetJobSkillDistributionFromDatabaseAsync(); + var studentSkills = await _aiRepo.GetStudentSkillsDistributionAsync(); + + var skillGaps = new List(); + + foreach (var jobSkill in jobSkills.Take(20)) + { + var studentsWithSkill = studentSkills.GetValueOrDefault(jobSkill.Key, 0); + var gap = jobSkill.Value - studentsWithSkill; + + skillGaps.Add(new SkillTrend + { + SkillName = jobSkill.Key, + JobPostingsCount = jobSkill.Value, + StudentsWithSkill = studentsWithSkill, + GapCount = gap > 0 ? gap : 0, + GrowthRate = gap > 0 ? (gap * 100.0 / jobSkill.Value) : 0 + }); + } + + return Ok(new + { + success = true, + message = "Skills gap analysis completed", + data = new + { + totalSkillsAnalyzed = jobSkills.Count, + criticalGaps = skillGaps.Where(s => s.GapCount > s.JobPostingsCount / 2).Count(), + skillGaps = skillGaps.OrderByDescending(s => s.GapCount).ToList(), + generatedAt = DateTime.UtcNow + } + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting skills gap analysis"); + return StatusCode(500, new { success = false, message = "Failed to retrieve skills gap analysis" }); } } @@ -373,6 +462,122 @@ private async Task> ProcessJobsInParallel( return results.ToList(); } + private async Task GetBasicInsightsAsync() + { + var stats = await _aiRepo.GetPlatformStatsAsync(); + var skills = await _aiRepo.GetJobSkillDistributionFromDatabaseAsync(); + var data = await _aiRepo.GetPlatformAnalyticsForAIAsync(); + + return new AdminAiInsightsResponse + { + TalentDemand = new TalentDemandInsights + { + MostSoughtAfterRole = skills.FirstOrDefault().Key ?? "Unknown", + FastestGrowingCategory = data.JobTrends.OrderByDescending(j => j.NewJobsLast3Months).FirstOrDefault()?.Category ?? "Technology", + AverageApplicantsPerJob = stats.AverageApplicantsPerJob, + TotalActiveJobs = stats.ActiveJobs, + TotalActiveStudents = stats.TotalStudents, + StudentToJobRatio = stats.ActiveJobs > 0 ? (double)stats.TotalStudents / stats.ActiveJobs : 0 + }, + PlatformHealth = new PlatformHealthInsights + { + MonthOverMonthGrowth = stats.NewStudentsLast30Days > 0 ? + (stats.NewStudentsLast30Days / (double)Math.Max(1, stats.TotalStudents - stats.NewStudentsLast30Days)) * 100 : 0, + ApplicationSuccessRate = stats.ApplicationSuccessRate, + CompaniesNeedingAttention = stats.StuckPendingCompanies, + Recommendations = new List + { + stats.StuckPendingCompanies > 0 ? $"Review {stats.StuckPendingCompanies} pending companies" : "No pending companies", + stats.AverageApplicantsPerJob < 5 ? "Consider promoting jobs to increase applications" : "Good application volume", + data.StudentEngagementRate < 50 ? "Improve student engagement with personalized job alerts" : "Student engagement is healthy" + }, + HealthScore = CalculateHealthScore(stats, data), + AlertLevel = stats.StuckPendingCompanies > 5 ? "Warning" : stats.StuckPendingCompanies > 0 ? "Attention" : "Good" + }, + TopInDemandSkills = skills.Take(10).Select(s => new SkillTrend + { + SkillName = s.Key, + JobPostingsCount = s.Value, + StudentsWithSkill = 0, + GapCount = s.Value, + GrowthRate = 10 + }).ToList(), + IndustryTrends = data.JobTrends.Take(5).Select(j => new IndustryTrend + { + Industry = j.Category, + Trend = j.NewJobsLast3Months > j.TotalJobs / 4 ? "Growing" : "Stable", + Insight = $"{j.Category} has {j.TotalJobs} positions, with {j.NewJobsLast3Months} new in last 3 months" + }).ToList(), + Predictions = new List + { + new Prediction + { + Metric = "Job Growth", + PredictionText = $"Expected to grow by {Math.Min(20, stats.NewStudentsLast30Days / 5)}% in next quarter based on current trends", + ConfidenceScore = 70, + Timeframe = "90 days" + }, + new Prediction + { + Metric = "Student Enrollment", + PredictionText = $"Expected {stats.NewStudentsLast30Days + 10} to {stats.NewStudentsLast30Days + 30} new students next month", + ConfidenceScore = 65, + Timeframe = "30 days" + } + }, + AiGeneratedSummary = $"Platform has {stats.TotalStudents} students and {stats.ActiveJobs} active jobs. " + + $"Student engagement is at {data.StudentEngagementRate:F1}% with {stats.ApplicationSuccessRate:F1}% success rate. " + + $"Top in-demand skill is {skills.FirstOrDefault().Key} with {skills.FirstOrDefault().Value} job postings.", + GeneratedAt = DateTime.UtcNow + }; + } + + private int CalculateHealthScore(AiAnalyticsRepository.PlatformStats stats, Repositories.PlatformAnalyticsData data) + { + int score = 50; + + if (data.StudentEngagementRate > 70) score += 20; + else if (data.StudentEngagementRate > 50) score += 15; + else if (data.StudentEngagementRate > 30) score += 10; + else if (data.StudentEngagementRate > 10) score += 5; + + if (stats.ApplicationSuccessRate > 30) score += 20; + else if (stats.ApplicationSuccessRate > 20) score += 15; + else if (stats.ApplicationSuccessRate > 10) score += 10; + else if (stats.ApplicationSuccessRate > 5) score += 5; + + if (stats.NewStudentsLast30Days > 100) score += 10; + else if (stats.NewStudentsLast30Days > 50) score += 7; + else if (stats.NewStudentsLast30Days > 20) score += 5; + else if (stats.NewStudentsLast30Days > 10) score += 3; + + if (stats.StuckPendingCompanies == 0) score += 10; + else if (stats.StuckPendingCompanies < 3) score += 5; + + var ratio = stats.ActiveJobs > 0 ? (double)stats.TotalStudents / stats.ActiveJobs : 0; + if (ratio >= 3 && ratio <= 10) score += 10; + else if (ratio >= 2 && ratio <= 15) score += 5; + + if (stats.ActiveJobs > 100) score += 10; + else if (stats.ActiveJobs > 50) score += 7; + else if (stats.ActiveJobs > 20) score += 5; + else if (stats.ActiveJobs > 10) score += 3; + + if (stats.AverageApplicantsPerJob > 20) score += 10; + else if (stats.AverageApplicantsPerJob > 10) score += 7; + else if (stats.AverageApplicantsPerJob > 5) score += 5; + else if (stats.AverageApplicantsPerJob > 2) score += 3; + + var growthRate = stats.NewStudentsLast30Days > 0 ? + (stats.NewStudentsLast30Days / (double)Math.Max(1, stats.TotalStudents - stats.NewStudentsLast30Days)) * 100 : 0; + if (growthRate > 20) score += 10; + else if (growthRate > 10) score += 7; + else if (growthRate > 5) score += 5; + else if (growthRate > 2) score += 3; + + return Math.Min(100, Math.Max(0, score)); + } + private int? GetCurrentUserId() { var userIdStr = User.FindFirst("userId")?.Value ?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value; diff --git a/DTOs/AdminAiInsightsResponse.cs b/DTOs/AdminAiInsightsResponse.cs index 187596b..9b8de83 100644 --- a/DTOs/AdminAiInsightsResponse.cs +++ b/DTOs/AdminAiInsightsResponse.cs @@ -10,6 +10,7 @@ public class AdminAiInsightsResponse public List TopInDemandSkills { get; set; } = new(); public List IndustryTrends { get; set; } = new(); public List Predictions { get; set; } = new(); + public string AiGeneratedSummary { get; set; } = ""; public DateTime GeneratedAt { get; set; } = DateTime.UtcNow; } @@ -29,6 +30,8 @@ public class PlatformHealthInsights public double ApplicationSuccessRate { get; set; } public int CompaniesNeedingAttention { get; set; } public List Recommendations { get; set; } = new(); + public int HealthScore { get; set; } + public string AlertLevel { get; set; } = "Good"; } public class SkillTrend @@ -43,9 +46,8 @@ public class SkillTrend public class IndustryTrend { public string Industry { get; set; } = ""; - public int JobCount { get; set; } - public int ApplicationCount { get; set; } - public double GrowthRate { get; set; } + public string Trend { get; set; } = ""; + public string Insight { get; set; } = ""; } public class Prediction diff --git a/Program.cs b/Program.cs index 87d9743..71ff852 100644 --- a/Program.cs +++ b/Program.cs @@ -43,6 +43,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // Add HTTP client for Gemini builder.Services.AddHttpClient(); diff --git a/Repositories/AiAnalyticsRepository.cs b/Repositories/AiAnalyticsRepository.cs index 28b5f5d..d72c022 100644 --- a/Repositories/AiAnalyticsRepository.cs +++ b/Repositories/AiAnalyticsRepository.cs @@ -143,9 +143,6 @@ FROM dbo.applications a return applicants; } - /// - /// Get REAL skill distribution from job requirements (not hardcoded) - /// public async Task> GetJobSkillDistributionFromDatabaseAsync() { var skills = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -198,35 +195,27 @@ public async Task GetPlatformStatsAsync() using var conn = _db.CreateConnection(); await conn.OpenAsync(); - // Total Students using (var cmd = new SqlCommand("SELECT COUNT(1) FROM dbo.students WHERE (is_deleted IS NULL OR is_deleted = 0)", conn)) stats.TotalStudents = Convert.ToInt32(await cmd.ExecuteScalarAsync()); - // New Students (Last 30 Days) using (var cmd = new SqlCommand("SELECT COUNT(1) FROM dbo.students WHERE (is_deleted IS NULL OR is_deleted = 0) AND created_at >= DATEADD(DAY, -30, SYSUTCDATETIME())", conn)) stats.NewStudentsLast30Days = Convert.ToInt32(await cmd.ExecuteScalarAsync()); - // Active Companies using (var cmd = new SqlCommand("SELECT COUNT(1) FROM dbo.companies WHERE status = 'APPROVED'", conn)) stats.ActiveCompanies = Convert.ToInt32(await cmd.ExecuteScalarAsync()); - // Active Jobs using (var cmd = new SqlCommand("SELECT COUNT(1) FROM dbo.jobs WHERE (is_deleted IS NULL OR is_deleted = 0) AND deadline >= CAST(SYSUTCDATETIME() AS DATE)", conn)) stats.ActiveJobs = Convert.ToInt32(await cmd.ExecuteScalarAsync()); - // Total Applications using (var cmd = new SqlCommand("SELECT COUNT(1) FROM dbo.applications", conn)) stats.TotalApplications = Convert.ToInt32(await cmd.ExecuteScalarAsync()); - // Accepted Applications using (var cmd = new SqlCommand("SELECT COUNT(1) FROM dbo.applications WHERE status = 'Accepted'", conn)) stats.AcceptedApplications = Convert.ToInt32(await cmd.ExecuteScalarAsync()); - // Stuck Pending Companies using (var cmd = new SqlCommand("SELECT COUNT(1) FROM dbo.companies WHERE status = 'PENDING_APPROVAL' AND created_at <= DATEADD(DAY, -7, SYSUTCDATETIME())", conn)) stats.StuckPendingCompanies = Convert.ToInt32(await cmd.ExecuteScalarAsync()); - // Average Applicants Per Job using (var cmd = new SqlCommand(@" SELECT ISNULL(AVG(ApplicationCount), 0) FROM ( SELECT COUNT(1) AS ApplicationCount FROM dbo.applications GROUP BY job_id @@ -242,7 +231,7 @@ SELECT COUNT(1) AS ApplicationCount FROM dbo.applications GROUP BY job_id return stats; } - // ========== WRITE METHODS (Storage) ========== + // ========== WRITE METHODS ========== public async Task SaveCvAnalysisResultAsync(CvAnalysisResult result) { @@ -306,7 +295,188 @@ INSERT INTO dbo.applicant_screening (application_id, screening_score, screening_ await cmd.ExecuteNonQueryAsync(); } - // ========== DTO Classes ========== + // ========== AI ANALYTICS METHODS ========== + + public async Task GetPlatformAnalyticsForAIAsync() + { + var data = new PlatformAnalyticsData(); + + using var conn = _db.CreateConnection(); + await conn.OpenAsync(); + + // Student engagement metrics - FIXED: CAST to FLOAT + const string engagementSql = @" + SELECT + COUNT(DISTINCT s.id) as TotalStudents, + COUNT(DISTINCT CASE WHEN a.id IS NOT NULL THEN s.id END) as StudentsWithApplications, + CAST(ISNULL(COUNT(DISTINCT CASE WHEN a.id IS NOT NULL THEN s.id END) * 100.0 / NULLIF(COUNT(DISTINCT s.id), 0), 0) AS FLOAT) as EngagementRate + FROM students s + LEFT JOIN applications a ON a.student_id = s.id + WHERE (s.is_deleted IS NULL OR s.is_deleted = 0)"; + + using (var cmd = new SqlCommand(engagementSql, conn)) + using (var reader = await cmd.ExecuteReaderAsync()) + { + if (await reader.ReadAsync()) + { + data.TotalStudents = reader.GetInt32(0); + data.StudentsWithApplications = reader.GetInt32(1); + data.StudentEngagementRate = reader.GetDouble(2); + } + } + + // Student trends + const string trendsSql = @" + SELECT + YEAR(created_at) as Year, + MONTH(created_at) as Month, + COUNT(*) as NewStudents + FROM students + WHERE created_at >= DATEADD(MONTH, -6, GETDATE()) + AND (is_deleted IS NULL OR is_deleted = 0) + GROUP BY YEAR(created_at), MONTH(created_at) + ORDER BY Year DESC, Month DESC"; + + using (var cmd = new SqlCommand(trendsSql, conn)) + using (var reader = await cmd.ExecuteReaderAsync()) + { + while (await reader.ReadAsync()) + { + data.StudentTrends.Add(new MonthlyTrend + { + Year = reader.GetInt32(0), + Month = reader.GetInt32(1), + Count = reader.GetInt32(2) + }); + } + } + + // Job trends by category + const string jobTrendsSql = @" + SELECT + category, + COUNT(*) as TotalJobs, + COUNT(CASE WHEN created_at >= DATEADD(MONTH, -3, GETDATE()) THEN 1 END) as NewJobs + FROM jobs + WHERE (is_deleted IS NULL OR is_deleted = 0) + AND category IS NOT NULL + GROUP BY category + ORDER BY TotalJobs DESC"; + + using (var cmd = new SqlCommand(jobTrendsSql, conn)) + using (var reader = await cmd.ExecuteReaderAsync()) + { + while (await reader.ReadAsync()) + { + data.JobTrends.Add(new CategoryTrend + { + Category = reader.GetString(0), + TotalJobs = reader.GetInt32(1), + NewJobsLast3Months = reader.GetInt32(2) + }); + } + } + + // Application status distribution - FIXED: CAST to FLOAT + const string appStatusSql = @" + SELECT + status, + COUNT(*) as Count, + CAST(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER() AS FLOAT) as Percentage + FROM applications + GROUP BY status"; + + using (var cmd = new SqlCommand(appStatusSql, conn)) + using (var reader = await cmd.ExecuteReaderAsync()) + { + while (await reader.ReadAsync()) + { + data.ApplicationStatusDistribution.Add(new StatusDistribution + { + Status = reader.GetString(0), + Count = reader.GetInt32(1), + Percentage = reader.GetDouble(2) + }); + } + } + + // Top companies + const string topCompaniesSql = @" + SELECT TOP 5 + c.company_name, + COUNT(j.id) as JobCount, + ISNULL(COUNT(a.id), 0) as TotalApplications + FROM companies c + LEFT JOIN jobs j ON j.company_id = c.id AND (j.is_deleted IS NULL OR j.is_deleted = 0) + LEFT JOIN applications a ON a.job_id = j.id + WHERE c.status = 'APPROVED' + GROUP BY c.company_name + ORDER BY JobCount DESC"; + + using (var cmd = new SqlCommand(topCompaniesSql, conn)) + using (var reader = await cmd.ExecuteReaderAsync()) + { + while (await reader.ReadAsync()) + { + data.TopCompanies.Add(new CompanyActivity + { + CompanyName = reader.GetString(0), + JobCount = reader.GetInt32(1), + TotalApplications = reader.GetInt32(2) + }); + } + } + + data.TopSkills = await GetJobSkillDistributionFromDatabaseAsync(); + data.StudentSkills = await GetStudentSkillsDistributionAsync(); + + return data; + } + + public async Task> GetStudentSkillsDistributionAsync() + { + var skills = new Dictionary(StringComparer.OrdinalIgnoreCase); + + const string sql = @" + SELECT skills, technical_skills + FROM student_profiles + WHERE skills IS NOT NULL OR technical_skills IS NOT NULL"; + + using var conn = _db.CreateConnection(); + await conn.OpenAsync(); + using var cmd = new SqlCommand(sql, conn); + using var reader = await cmd.ExecuteReaderAsync(); + + var commonSkills = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "C#", "JavaScript", "Python", "Java", "SQL", "TypeScript", "React", + "Angular", "Vue", "Node.js", ".NET", "Spring Boot", "Django", "Flask", + "AWS", "Azure", "Docker", "Kubernetes", "Git", "REST API", "GraphQL", + "MongoDB", "PostgreSQL", "MySQL", "Redis", "Go", "Rust", "Swift" + }; + + while (await reader.ReadAsync()) + { + var allSkills = ""; + if (!reader.IsDBNull(0)) allSkills += reader.GetString(0); + if (!reader.IsDBNull(1)) allSkills += " " + reader.GetString(1); + + foreach (var skill in commonSkills) + { + if (allSkills.Contains(skill, StringComparison.OrdinalIgnoreCase)) + { + if (skills.ContainsKey(skill)) + skills[skill]++; + else + skills[skill] = 1; + } + } + } + + return skills; + } + + // ========== DTO CLASSES ========== public class PlatformStats { @@ -322,6 +492,8 @@ public class PlatformStats } } + // ========== EXTERNAL DTO CLASSES ========== + public class JobInfo { public int Id { get; set; } @@ -343,4 +515,45 @@ public class ApplicantInfo public string Status { get; set; } = ""; public DateTime AppliedDate { get; set; } } + + public class PlatformAnalyticsData + { + public int TotalStudents { get; set; } + public int StudentsWithApplications { get; set; } + public double StudentEngagementRate { get; set; } + public List StudentTrends { get; set; } = new(); + public List JobTrends { get; set; } = new(); + public List ApplicationStatusDistribution { get; set; } = new(); + public List TopCompanies { get; set; } = new(); + public Dictionary TopSkills { get; set; } = new(); + public Dictionary StudentSkills { get; set; } = new(); + } + + public class MonthlyTrend + { + public int Year { get; set; } + public int Month { get; set; } + public int Count { get; set; } + } + + public class CategoryTrend + { + public string Category { get; set; } = ""; + public int TotalJobs { get; set; } + public int NewJobsLast3Months { get; set; } + } + + public class StatusDistribution + { + public string Status { get; set; } = ""; + public int Count { get; set; } + public double Percentage { get; set; } + } + + public class CompanyActivity + { + public string CompanyName { get; set; } = ""; + public int JobCount { get; set; } + public int TotalApplications { get; set; } + } } \ No newline at end of file diff --git a/Services/AiInsightsGeneratorService.cs b/Services/AiInsightsGeneratorService.cs new file mode 100644 index 0000000..6841738 --- /dev/null +++ b/Services/AiInsightsGeneratorService.cs @@ -0,0 +1,299 @@ +using System.Text; +using System.Text.Json; +using PATHFINDER_BACKEND.DTOs; +using PATHFINDER_BACKEND.Repositories; + +namespace PATHFINDER_BACKEND.Services +{ + public class AiInsightsGeneratorService + { + private readonly GeminiAIService _gemini; + private readonly ILogger _logger; + private readonly AiAnalyticsRepository _aiRepo; + + public AiInsightsGeneratorService( + GeminiAIService gemini, + ILogger logger, + AiAnalyticsRepository aiRepo) + { + _gemini = gemini; + _logger = logger; + _aiRepo = aiRepo; + } + + public async Task GenerateAiInsightsAsync() + { + try + { + var platformData = await _aiRepo.GetPlatformAnalyticsForAIAsync(); + var stats = await _aiRepo.GetPlatformStatsAsync(); + + var aiAnalysis = await GetAiAnalysisAsync(platformData, stats); + var skillGaps = CalculateSkillGaps(platformData.TopSkills, platformData.StudentSkills); + + // Convert string industry trends to IndustryTrend objects + var industryTrends = aiAnalysis.IndustryTrends.Select(t => new IndustryTrend + { + Industry = t.Length > 50 ? t.Substring(0, 50) : t, + Trend = "Observed", + Insight = t + }).ToList(); + + var insights = new AdminAiInsightsResponse + { + TalentDemand = new TalentDemandInsights + { + MostSoughtAfterRole = aiAnalysis.MostSoughtAfterRole, + FastestGrowingCategory = aiAnalysis.FastestGrowingCategory, + AverageApplicantsPerJob = stats.AverageApplicantsPerJob, + TotalActiveJobs = stats.ActiveJobs, + TotalActiveStudents = stats.TotalStudents, + StudentToJobRatio = stats.ActiveJobs > 0 ? (double)stats.TotalStudents / stats.ActiveJobs : 0 + }, + PlatformHealth = new PlatformHealthInsights + { + MonthOverMonthGrowth = stats.NewStudentsLast30Days > 0 ? + (stats.NewStudentsLast30Days / (double)Math.Max(1, stats.TotalStudents - stats.NewStudentsLast30Days)) * 100 : 0, + ApplicationSuccessRate = stats.ApplicationSuccessRate, + CompaniesNeedingAttention = stats.StuckPendingCompanies, + Recommendations = aiAnalysis.Recommendations, + HealthScore = aiAnalysis.HealthScore, + AlertLevel = aiAnalysis.AlertLevel + }, + TopInDemandSkills = skillGaps, + IndustryTrends = industryTrends, + Predictions = aiAnalysis.Predictions, + AiGeneratedSummary = aiAnalysis.Summary, + GeneratedAt = DateTime.UtcNow + }; + + return insights; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to generate AI insights, falling back to basic insights"); + return await GetFallbackInsightsAsync(); + } + } + + private async Task GetAiAnalysisAsync(PlatformAnalyticsData data, AiAnalyticsRepository.PlatformStats stats) + { + var systemInstruction = "You are an expert data analyst and recruitment platform strategist. Analyze the platform data and provide strategic insights. Return ONLY valid JSON."; + + var jobTrendsJson = JsonSerializer.Serialize(data.JobTrends.Take(10)); + var studentTrendsJson = JsonSerializer.Serialize(data.StudentTrends); + var appStatusJson = JsonSerializer.Serialize(data.ApplicationStatusDistribution); + var topCompaniesJson = JsonSerializer.Serialize(data.TopCompanies); + var topSkillsJson = JsonSerializer.Serialize(data.TopSkills.Take(10)); + var studentSkillsJson = JsonSerializer.Serialize(data.StudentSkills.Take(10)); + + var prompt = $@" +Analyze this job platform data and provide strategic insights: + +## PLATFORM OVERVIEW +- Total Students: {data.TotalStudents} +- Students with Applications: {data.StudentsWithApplications} ({data.StudentEngagementRate:F1}% engagement) +- Active Jobs: {stats.ActiveJobs} +- Total Applications: {stats.TotalApplications} +- Application Success Rate: {stats.ApplicationSuccessRate:F1}% + +## JOB TRENDS BY CATEGORY +{jobTrendsJson} + +## STUDENT GROWTH TRENDS (Last 6 months) +{studentTrendsJson} + +## APPLICATION STATUS DISTRIBUTION +{appStatusJson} + +## TOP 5 ACTIVE COMPANIES +{topCompaniesJson} + +## TOP SKILLS IN DEMAND +{topSkillsJson} + +## STUDENT SKILLS AVAILABLE +{studentSkillsJson} + +## REQUIRED OUTPUT (JSON only) - IMPORTANT: industryTrends must be an array of strings, not objects: +{{ + ""mostSoughtAfterRole"": ""string - the most in-demand job title/role based on job postings"", + ""fastestGrowingCategory"": ""string - fastest growing job category based on 3-month trend"", + ""healthScore"": 0, + ""alertLevel"": ""string - 'Critical', 'Warning', or 'Good'"", + ""recommendations"": [ ""string - actionable recommendations"" ], + ""industryTrends"": [ ""string - each trend as a simple string, not an object"" ], + ""predictions"": [ + {{ + ""metric"": ""string - e.g., 'Job Growth', 'Student Enrollment'"", + ""predictionText"": ""string - AI-generated prediction"", + ""confidenceScore"": 0, + ""timeframe"": ""string - e.g., '30 days', '90 days'"" + }} + ], + ""summary"": ""string - 2-3 sentence executive summary of platform health"" +}}"; + + try + { + var result = await _gemini.GenerateStructuredContentAsync(prompt, systemInstruction); + return result; + } + catch (Exception ex) + { + _logger.LogError(ex, "AI analysis failed, using fallback"); + return GetFallbackAnalysisResult(data, stats); + } + } + + private List CalculateSkillGaps(Dictionary jobSkills, Dictionary studentSkills) + { + var skillGaps = new List(); + + foreach (var jobSkill in jobSkills.Take(15)) + { + var studentsWithSkill = studentSkills.GetValueOrDefault(jobSkill.Key, 0); + var gap = jobSkill.Value - studentsWithSkill; + + skillGaps.Add(new SkillTrend + { + SkillName = jobSkill.Key, + JobPostingsCount = jobSkill.Value, + StudentsWithSkill = studentsWithSkill, + GapCount = gap > 0 ? gap : 0, + GrowthRate = jobSkill.Value > 10 ? 15 : 5 + }); + } + + return skillGaps.OrderByDescending(s => s.GapCount).ToList(); + } + + private AiAnalysisResult GetFallbackAnalysisResult(PlatformAnalyticsData data, AiAnalyticsRepository.PlatformStats stats) + { + var firstCategory = data.JobTrends.FirstOrDefault()?.Category ?? "Software Developer"; + var growingCategory = data.JobTrends.OrderByDescending(j => j.NewJobsLast3Months).FirstOrDefault()?.Category ?? "Technology"; + + return new AiAnalysisResult + { + MostSoughtAfterRole = firstCategory, + FastestGrowingCategory = growingCategory, + HealthScore = CalculateHealthScore(data, stats), + AlertLevel = stats.StuckPendingCompanies > 5 ? "Warning" : "Good", + Recommendations = new List + { + stats.AverageApplicantsPerJob < 3 ? "Consider marketing campaigns to attract more applicants" : "Good application volume", + data.StudentEngagementRate < 50 ? "Improve student engagement with personalized job alerts" : "Student engagement is healthy", + stats.StuckPendingCompanies > 0 ? $"Review {stats.StuckPendingCompanies} pending company registrations" : "All companies processed" + }, + IndustryTrends = new List(), + Predictions = new List + { + new Prediction + { + Metric = "Job Growth", + PredictionText = $"Expected to grow by {Math.Min(20, stats.NewStudentsLast30Days / 5)}% in next quarter", + ConfidenceScore = 75, + Timeframe = "90 days" + } + }, + Summary = $"Platform has {stats.TotalStudents} students and {stats.ActiveJobs} active jobs. " + + $"Student engagement is at {data.StudentEngagementRate:F1}% with {stats.ApplicationSuccessRate:F1}% success rate." + }; + } + + private int CalculateHealthScore(PlatformAnalyticsData data, AiAnalyticsRepository.PlatformStats stats) + { + int score = 50; + + if (data.StudentEngagementRate > 70) score += 20; + else if (data.StudentEngagementRate > 50) score += 15; + else if (data.StudentEngagementRate > 30) score += 10; + else if (data.StudentEngagementRate > 10) score += 5; + + if (stats.ApplicationSuccessRate > 30) score += 20; + else if (stats.ApplicationSuccessRate > 20) score += 15; + else if (stats.ApplicationSuccessRate > 10) score += 10; + else if (stats.ApplicationSuccessRate > 5) score += 5; + + if (stats.NewStudentsLast30Days > 100) score += 10; + else if (stats.NewStudentsLast30Days > 50) score += 7; + else if (stats.NewStudentsLast30Days > 20) score += 5; + else if (stats.NewStudentsLast30Days > 10) score += 3; + + if (stats.StuckPendingCompanies == 0) score += 10; + else if (stats.StuckPendingCompanies < 3) score += 5; + + var ratio = stats.ActiveJobs > 0 ? (double)stats.TotalStudents / stats.ActiveJobs : 0; + if (ratio >= 3 && ratio <= 10) score += 10; + else if (ratio >= 2 && ratio <= 15) score += 5; + + return Math.Min(100, Math.Max(0, score)); + } + + private async Task GetFallbackInsightsAsync() + { + var stats = await _aiRepo.GetPlatformStatsAsync(); + var skills = await _aiRepo.GetJobSkillDistributionFromDatabaseAsync(); + + return new AdminAiInsightsResponse + { + TalentDemand = new TalentDemandInsights + { + MostSoughtAfterRole = skills.FirstOrDefault().Key ?? "Unknown", + FastestGrowingCategory = "Technology", + AverageApplicantsPerJob = stats.AverageApplicantsPerJob, + TotalActiveJobs = stats.ActiveJobs, + TotalActiveStudents = stats.TotalStudents, + StudentToJobRatio = stats.ActiveJobs > 0 ? (double)stats.TotalStudents / stats.ActiveJobs : 0 + }, + PlatformHealth = new PlatformHealthInsights + { + MonthOverMonthGrowth = stats.NewStudentsLast30Days > 0 ? + (stats.NewStudentsLast30Days / (double)Math.Max(1, stats.TotalStudents - stats.NewStudentsLast30Days)) * 100 : 0, + ApplicationSuccessRate = stats.ApplicationSuccessRate, + CompaniesNeedingAttention = stats.StuckPendingCompanies, + Recommendations = new List + { + stats.StuckPendingCompanies > 0 ? $"Review {stats.StuckPendingCompanies} pending companies" : "No pending companies", + stats.AverageApplicantsPerJob < 5 ? "Consider promoting jobs to increase applications" : "Good application volume" + }, + HealthScore = 70, + AlertLevel = stats.StuckPendingCompanies > 5 ? "Warning" : "Good" + }, + TopInDemandSkills = skills.Take(10).Select(s => new SkillTrend + { + SkillName = s.Key, + JobPostingsCount = s.Value, + StudentsWithSkill = 0, + GapCount = s.Value, + GrowthRate = 10 + }).ToList(), + IndustryTrends = new List(), + Predictions = new List + { + new Prediction + { + Metric = "Job Growth", + PredictionText = "Expected to grow steadily", + ConfidenceScore = 70, + Timeframe = "30 days" + } + }, + AiGeneratedSummary = "AI insights temporarily unavailable. Showing basic analytics.", + GeneratedAt = DateTime.UtcNow + }; + } + } + + public class AiAnalysisResult + { + public string MostSoughtAfterRole { get; set; } = ""; + public string FastestGrowingCategory { get; set; } = ""; + public int HealthScore { get; set; } + public string AlertLevel { get; set; } = ""; + public List Recommendations { get; set; } = new(); + public List IndustryTrends { get; set; } = new(); + public List Predictions { get; set; } = new(); + public string Summary { get; set; } = ""; + } +} \ No newline at end of file diff --git a/Services/GeminiAIService.cs b/Services/GeminiAIService.cs index 12908fb..3986976 100644 --- a/Services/GeminiAIService.cs +++ b/Services/GeminiAIService.cs @@ -9,22 +9,45 @@ public class GeminiAIService { private readonly HttpClient _httpClient; private readonly string _apiKey; - private readonly string _model = "gemini-2.5-flash"; + private readonly string _model; private readonly ILogger _logger; + private readonly bool _isEnabled; public GeminiAIService(IConfiguration configuration, ILogger logger) { _logger = logger; - _apiKey = configuration["Gemini:ApiKey"] ?? throw new Exception("Gemini:ApiKey is missing"); - _httpClient = new HttpClient(); - _httpClient.BaseAddress = new Uri("https://generativelanguage.googleapis.com/v1beta/"); - _httpClient.Timeout = TimeSpan.FromSeconds(60); + _apiKey = configuration["Gemini:ApiKey"] ?? ""; + _model = configuration["Gemini:Model"] ?? "gemini-2.5-flash"; + + if (string.IsNullOrEmpty(_apiKey)) + { + _logger.LogWarning("Gemini API key is missing. AI features will be disabled."); + _isEnabled = false; + _httpClient = new HttpClient(); + } + else + { + _isEnabled = true; + _httpClient = new HttpClient(); + _httpClient.Timeout = TimeSpan.FromSeconds(120); + _logger.LogInformation($"Gemini AI Service initialized with model: {_model}"); + } } public async Task GenerateContentAsync(string prompt, string? systemInstruction = null) { + if (!_isEnabled) + { + _logger.LogWarning("Gemini AI is disabled. API key not configured."); + throw new InvalidOperationException("AI features are disabled. Please configure Gemini API key."); + } + try { + var url = $"https://generativelanguage.googleapis.com/v1beta/models/{_model}:generateContent?key={_apiKey}"; + + _logger.LogInformation($"Calling Gemini API with model: {_model}"); + var requestBody = new { contents = new[] @@ -43,29 +66,52 @@ public async Task GenerateContentAsync(string prompt, string? systemInst temperature = 0.2, topP = 0.95, topK = 40, - maxOutputTokens = 8192 + maxOutputTokens = 4096 // Increased to get complete response } }; - var json = JsonSerializer.Serialize(requestBody); + var json = JsonSerializer.Serialize(requestBody, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }); + var content = new StringContent(json, Encoding.UTF8, "application/json"); - var url = $"{_model}:generateContent?key={_apiKey}"; var response = await _httpClient.PostAsync(url, content); var responseJson = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { _logger.LogError($"Gemini API error: {response.StatusCode} - {responseJson}"); - throw new Exception($"Gemini API error: {response.StatusCode}"); + + try + { + using var errorDoc = JsonDocument.Parse(responseJson); + var error = errorDoc.RootElement.GetProperty("error"); + var message = error.GetProperty("message").GetString(); + throw new Exception($"Gemini API error: {message}"); + } + catch + { + throw new Exception($"Gemini API error: {response.StatusCode}"); + } } - using var doc = JsonDocument.Parse(responseJson); - return doc.RootElement - .GetProperty("candidates")[0] + using var resultDoc = JsonDocument.Parse(responseJson); + var candidates = resultDoc.RootElement.GetProperty("candidates"); + + if (candidates.GetArrayLength() == 0) + { + throw new Exception("No candidates returned from Gemini API"); + } + + var text = candidates[0] .GetProperty("content") .GetProperty("parts")[0] .GetProperty("text") - .GetString() ?? ""; + .GetString(); + + return text ?? ""; } catch (Exception ex) { @@ -77,15 +123,78 @@ public async Task GenerateContentAsync(string prompt, string? systemInst public async Task GenerateStructuredContentAsync(string prompt, string? systemInstruction = null) { var textResponse = await GenerateContentAsync(prompt, systemInstruction); - var jsonStart = textResponse.IndexOf('{'); - var jsonEnd = textResponse.LastIndexOf('}') + 1; + + // Extract JSON from the response (handles markdown, incomplete JSON, etc.) + var jsonString = ExtractJsonFromResponse(textResponse); + + if (string.IsNullOrEmpty(jsonString)) + { + _logger.LogError($"Could not extract JSON from response. Raw response: {textResponse.Substring(0, Math.Min(500, textResponse.Length))}"); + throw new Exception("Response did not contain valid JSON"); + } + + try + { + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + + return JsonSerializer.Deserialize(jsonString, options) ?? throw new Exception("Failed to deserialize JSON"); + } + catch (JsonException ex) + { + _logger.LogError(ex, $"Failed to parse JSON. Attempted to parse: {jsonString.Substring(0, Math.Min(500, jsonString.Length))}"); + throw; + } + } - if (jsonStart >= 0 && jsonEnd > jsonStart) + private string ExtractJsonFromResponse(string response) + { + if (string.IsNullOrWhiteSpace(response)) + return ""; + + var cleaned = response.Trim(); + + // Remove markdown code blocks + if (cleaned.Contains("```json")) + { + var start = cleaned.IndexOf("```json") + 7; + var end = cleaned.LastIndexOf("```"); + if (end > start) + { + cleaned = cleaned.Substring(start, end - start); + } + } + else if (cleaned.Contains("```")) { - var jsonString = textResponse.Substring(jsonStart, jsonEnd - jsonStart); - return JsonSerializer.Deserialize(jsonString) ?? throw new Exception("Failed to parse JSON"); + var start = cleaned.IndexOf("```") + 3; + var end = cleaned.LastIndexOf("```"); + if (end > start) + { + cleaned = cleaned.Substring(start, end - start); + } + } + + cleaned = cleaned.Trim(); + + // Find the first { and last } + var firstBrace = cleaned.IndexOf('{'); + var lastBrace = cleaned.LastIndexOf('}'); + + if (firstBrace >= 0 && lastBrace > firstBrace) + { + var json = cleaned.Substring(firstBrace, lastBrace - firstBrace + 1); + + // Validate it's valid JSON (or close enough) + if (json.StartsWith("{") && json.EndsWith("}")) + { + return json; + } } - throw new Exception("Response did not contain valid JSON"); + + // If we couldn't extract, return the original cleaned response + return cleaned; } } } \ No newline at end of file diff --git a/appsettings.json b/appsettings.json index ff2bd3f..d370dfe 100644 --- a/appsettings.json +++ b/appsettings.json @@ -33,7 +33,8 @@ "BaseUrl": "http://localhost:5249" }, "Gemini": { - "ApiKey": "" + "ApiKey": "", + "Model": "gemini-2.5-flash" }, "AllowedHosts": "*" } \ No newline at end of file