242 lines
8.8 KiB
C#
242 lines
8.8 KiB
C#
|
|
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<ProcessCVCheckJob> _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<ProcessCVCheckJob> 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<CompanyVerificationResult>();
|
||
|
|
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<FlagCategory>(flag.Category),
|
||
|
|
Severity = Enum.Parse<FlagSeverity>(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<FlagResult> Flags) CalculateVeracityScore(
|
||
|
|
List<CompanyVerificationResult> verifications,
|
||
|
|
TimelineAnalysisResult timeline)
|
||
|
|
{
|
||
|
|
var score = BaseScore;
|
||
|
|
var flags = new List<FlagResult>();
|
||
|
|
|
||
|
|
// 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"
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|