using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using TrueCV.Application.Interfaces; using TrueCV.Application.Models; using TrueCV.Domain.Entities; using TrueCV.Domain.Enums; using TrueCV.Infrastructure.Data; namespace TrueCV.Infrastructure.Jobs; public sealed class ProcessCVCheckJob { private readonly ApplicationDbContext _dbContext; private readonly IFileStorageService _fileStorageService; private readonly ICVParserService _cvParserService; private readonly ICompanyVerifierService _companyVerifierService; private readonly ITimelineAnalyserService _timelineAnalyserService; private readonly ILogger _logger; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true }; private const int BaseScore = 100; private const int UnverifiedCompanyPenalty = 10; private const int GapMonthPenalty = 1; private const int MaxGapPenalty = 10; private const int OverlapMonthPenalty = 2; public ProcessCVCheckJob( ApplicationDbContext dbContext, IFileStorageService fileStorageService, ICVParserService cvParserService, ICompanyVerifierService companyVerifierService, ITimelineAnalyserService timelineAnalyserService, ILogger logger) { _dbContext = dbContext; _fileStorageService = fileStorageService; _cvParserService = cvParserService; _companyVerifierService = companyVerifierService; _timelineAnalyserService = timelineAnalyserService; _logger = logger; } public async Task ExecuteAsync(Guid cvCheckId, CancellationToken cancellationToken) { _logger.LogInformation("Starting CV check processing for: {CheckId}", cvCheckId); var cvCheck = await _dbContext.CVChecks .FirstOrDefaultAsync(c => c.Id == cvCheckId, cancellationToken); if (cvCheck is null) { _logger.LogError("CV check not found: {CheckId}", cvCheckId); return; } try { // Step 1: Update status to Processing cvCheck.Status = CheckStatus.Processing; await _dbContext.SaveChangesAsync(cancellationToken); _logger.LogDebug("CV check {CheckId} status updated to Processing", cvCheckId); // Step 2: Download file from blob await using var fileStream = await _fileStorageService.DownloadAsync(cvCheck.BlobUrl); _logger.LogDebug("Downloaded CV file for check {CheckId}", cvCheckId); // Step 3: Parse CV var cvData = await _cvParserService.ParseAsync(fileStream, cvCheck.OriginalFileName); _logger.LogDebug( "Parsed CV for check {CheckId}: {EmploymentCount} employment entries", cvCheckId, cvData.Employment.Count); // Step 4: Save extracted data cvCheck.ExtractedDataJson = JsonSerializer.Serialize(cvData, JsonOptions); await _dbContext.SaveChangesAsync(cancellationToken); // Step 5: Verify each employment entry var verificationResults = new List(); foreach (var employment in cvData.Employment) { var result = await _companyVerifierService.VerifyCompanyAsync( employment.CompanyName, employment.StartDate, employment.EndDate); verificationResults.Add(result); _logger.LogDebug( "Verified {Company}: {IsVerified} (Score: {Score}%)", employment.CompanyName, result.IsVerified, result.MatchScore); } // Step 6: Analyse timeline var timelineAnalysis = _timelineAnalyserService.Analyse(cvData.Employment); _logger.LogDebug( "Timeline analysis for check {CheckId}: {GapCount} gaps, {OverlapCount} overlaps", cvCheckId, timelineAnalysis.Gaps.Count, timelineAnalysis.Overlaps.Count); // Step 7: Calculate veracity score var (score, flags) = CalculateVeracityScore(verificationResults, timelineAnalysis); _logger.LogDebug("Calculated veracity score for check {CheckId}: {Score}", cvCheckId, score); // Step 8: Create CVFlag records foreach (var flag in flags) { var cvFlag = new CVFlag { Id = Guid.NewGuid(), CVCheckId = cvCheckId, Category = Enum.Parse(flag.Category), Severity = Enum.Parse(flag.Severity), Title = flag.Title, Description = flag.Description, ScoreImpact = flag.ScoreImpact }; _dbContext.CVFlags.Add(cvFlag); } // Step 9: Generate veracity report var report = new VeracityReport { OverallScore = score, ScoreLabel = GetScoreLabel(score), EmploymentVerifications = verificationResults, TimelineAnalysis = timelineAnalysis, Flags = flags, GeneratedAt = DateTime.UtcNow }; cvCheck.ReportJson = JsonSerializer.Serialize(report, JsonOptions); cvCheck.VeracityScore = score; // Step 10: Update status to Completed cvCheck.Status = CheckStatus.Completed; cvCheck.CompletedAt = DateTime.UtcNow; await _dbContext.SaveChangesAsync(cancellationToken); _logger.LogInformation( "CV check {CheckId} completed successfully with score {Score}", cvCheckId, score); } catch (Exception ex) { _logger.LogError(ex, "Error processing CV check {CheckId}", cvCheckId); cvCheck.Status = CheckStatus.Failed; await _dbContext.SaveChangesAsync(cancellationToken); throw; } } private static (int Score, List Flags) CalculateVeracityScore( List verifications, TimelineAnalysisResult timeline) { var score = BaseScore; var flags = new List(); // Penalty for unverified companies foreach (var verification in verifications.Where(v => !v.IsVerified)) { score -= UnverifiedCompanyPenalty; flags.Add(new FlagResult { Category = FlagCategory.Employment.ToString(), Severity = FlagSeverity.Warning.ToString(), Title = "Unverified Company", Description = $"Could not verify employment at '{verification.ClaimedCompany}'. {verification.VerificationNotes}", ScoreImpact = -UnverifiedCompanyPenalty }); } // Penalty for gaps (max -10 per gap) foreach (var gap in timeline.Gaps) { var gapPenalty = Math.Min(gap.Months * GapMonthPenalty, MaxGapPenalty); score -= gapPenalty; var severity = gap.Months >= 6 ? FlagSeverity.Warning : FlagSeverity.Info; flags.Add(new FlagResult { Category = FlagCategory.Timeline.ToString(), Severity = severity.ToString(), Title = "Employment Gap", Description = $"{gap.Months} month gap in employment from {gap.StartDate:MMM yyyy} to {gap.EndDate:MMM yyyy}", ScoreImpact = -gapPenalty }); } // Penalty for overlaps (only if > 2 months) foreach (var overlap in timeline.Overlaps) { var excessMonths = overlap.Months - 2; // Allow 2 month transition var overlapPenalty = excessMonths * OverlapMonthPenalty; score -= overlapPenalty; var severity = overlap.Months >= 6 ? FlagSeverity.Critical : FlagSeverity.Warning; flags.Add(new FlagResult { Category = FlagCategory.Timeline.ToString(), Severity = severity.ToString(), Title = "Employment Overlap", Description = $"{overlap.Months} month overlap between '{overlap.Company1}' and '{overlap.Company2}' ({overlap.OverlapStart:MMM yyyy} to {overlap.OverlapEnd:MMM yyyy})", ScoreImpact = -overlapPenalty }); } // Ensure score doesn't go below 0 score = Math.Max(0, score); return (score, flags); } private static string GetScoreLabel(int score) { return score switch { >= 90 => "Excellent", >= 75 => "Good", >= 60 => "Fair", >= 40 => "Poor", _ => "Very Poor" }; } }