2026-01-18 19:20:50 +01:00
using System.Text.Json ;
using Microsoft.EntityFrameworkCore ;
using Microsoft.Extensions.Logging ;
2026-01-20 16:45:43 +01:00
using TrueCV.Application.Helpers ;
2026-01-18 19:20:50 +01:00
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 ;
2026-01-20 16:45:43 +01:00
private readonly IEducationVerifierService _educationVerifierService ;
2026-01-18 19:20:50 +01:00
private readonly ITimelineAnalyserService _timelineAnalyserService ;
2026-01-20 20:58:12 +01:00
private readonly IAuditService _auditService ;
2026-01-18 19:20:50 +01:00
private readonly ILogger < ProcessCVCheckJob > _logger ;
private const int BaseScore = 100 ;
private const int UnverifiedCompanyPenalty = 10 ;
2026-01-20 20:00:24 +01:00
private const int ImplausibleJobTitlePenalty = 15 ;
private const int CompanyVerificationFlagPenalty = 5 ; // Base penalty for company flags, actual from flag.ScoreImpact
private const int RapidProgressionPenalty = 10 ;
private const int EarlyCareerSeniorRolePenalty = 10 ;
2026-01-18 19:20:50 +01:00
private const int GapMonthPenalty = 1 ;
private const int MaxGapPenalty = 10 ;
private const int OverlapMonthPenalty = 2 ;
2026-01-20 16:45:43 +01:00
private const int DiplomaMillPenalty = 25 ;
private const int SuspiciousInstitutionPenalty = 15 ;
private const int UnverifiedEducationPenalty = 5 ;
private const int EducationDatePenalty = 10 ;
2026-01-18 19:20:50 +01:00
public ProcessCVCheckJob (
ApplicationDbContext dbContext ,
IFileStorageService fileStorageService ,
ICVParserService cvParserService ,
ICompanyVerifierService companyVerifierService ,
2026-01-20 16:45:43 +01:00
IEducationVerifierService educationVerifierService ,
2026-01-18 19:20:50 +01:00
ITimelineAnalyserService timelineAnalyserService ,
2026-01-20 20:58:12 +01:00
IAuditService auditService ,
2026-01-18 19:20:50 +01:00
ILogger < ProcessCVCheckJob > logger )
{
_dbContext = dbContext ;
_fileStorageService = fileStorageService ;
_cvParserService = cvParserService ;
_companyVerifierService = companyVerifierService ;
2026-01-20 16:45:43 +01:00
_educationVerifierService = educationVerifierService ;
2026-01-18 19:20:50 +01:00
_timelineAnalyserService = timelineAnalyserService ;
2026-01-20 20:58:12 +01:00
_auditService = auditService ;
2026-01-18 19:20:50 +01:00
_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 ;
2026-01-20 20:58:12 +01:00
cvCheck . ProcessingStage = "Downloading CV" ;
2026-01-18 19:20:50 +01:00
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
2026-01-20 20:58:12 +01:00
cvCheck . ProcessingStage = "Parsing CV" ;
await _dbContext . SaveChangesAsync ( cancellationToken ) ;
2026-01-20 16:45:43 +01:00
var cvData = await _cvParserService . ParseAsync ( fileStream , cvCheck . OriginalFileName , cancellationToken ) ;
2026-01-18 19:20:50 +01:00
_logger . LogDebug (
"Parsed CV for check {CheckId}: {EmploymentCount} employment entries" ,
cvCheckId , cvData . Employment . Count ) ;
// Step 4: Save extracted data
2026-01-20 16:45:43 +01:00
cvCheck . ExtractedDataJson = JsonSerializer . Serialize ( cvData , JsonDefaults . CamelCaseIndented ) ;
2026-01-20 20:58:12 +01:00
cvCheck . ProcessingStage = "Verifying Employment" ;
2026-01-18 19:20:50 +01:00
await _dbContext . SaveChangesAsync ( cancellationToken ) ;
2026-01-20 16:45:43 +01:00
// Step 5: Verify each employment entry (parallelized with rate limiting)
2026-01-20 20:00:24 +01:00
// Skip freelance entries as they cannot be verified against company registries
var verificationTasks = cvData . Employment
. Where ( e = > ! IsFreelance ( e . CompanyName ) )
. Select ( async employment = >
{
var result = await _companyVerifierService . VerifyCompanyAsync (
employment . CompanyName ,
employment . StartDate ,
employment . EndDate ,
employment . JobTitle ) ;
2026-01-18 19:20:50 +01:00
2026-01-20 20:00:24 +01:00
_logger . LogDebug (
"Verified {Company}: {IsVerified} (Score: {Score}%), JobTitle: {JobTitle}, Plausible: {Plausible}" ,
employment . CompanyName , result . IsVerified , result . MatchScore ,
employment . JobTitle , result . JobTitlePlausible ) ;
2026-01-18 19:20:50 +01:00
2026-01-20 20:00:24 +01:00
return result ;
} ) ;
2026-01-20 16:45:43 +01:00
var verificationResults = ( await Task . WhenAll ( verificationTasks ) ) . ToList ( ) ;
2026-01-20 20:00:24 +01:00
// Add freelance entries as auto-verified (skipped)
foreach ( var employment in cvData . Employment . Where ( e = > IsFreelance ( e . CompanyName ) ) )
{
verificationResults . Add ( new CompanyVerificationResult
{
ClaimedCompany = employment . CompanyName ,
IsVerified = true ,
MatchScore = 100 ,
VerificationNotes = "Freelance/self-employed - verification skipped" ,
ClaimedJobTitle = employment . JobTitle ,
JobTitlePlausible = true
} ) ;
_logger . LogDebug ( "Skipped verification for freelance entry: {Company}" , employment . CompanyName ) ;
}
// Step 5b: Verify director claims against Companies House officers
2026-01-20 20:58:12 +01:00
cvCheck . ProcessingStage = "Verifying Directors" ;
await _dbContext . SaveChangesAsync ( cancellationToken ) ;
2026-01-20 20:00:24 +01:00
await VerifyDirectorClaims ( cvData . FullName , verificationResults , cancellationToken ) ;
2026-01-20 16:45:43 +01:00
// Step 6: Verify education entries
2026-01-20 20:58:12 +01:00
cvCheck . ProcessingStage = "Verifying Education" ;
await _dbContext . SaveChangesAsync ( cancellationToken ) ;
2026-01-20 16:45:43 +01:00
var educationResults = _educationVerifierService . VerifyAll (
cvData . Education ,
cvData . Employment ) ;
_logger . LogDebug (
"Education verification for check {CheckId}: {Count} entries verified ({Recognised} recognised, {DiplomaMill} diploma mills)" ,
cvCheckId ,
educationResults . Count ,
educationResults . Count ( e = > e . IsVerified ) ,
educationResults . Count ( e = > e . IsDiplomaMill ) ) ;
// Step 7: Analyse timeline
2026-01-20 20:58:12 +01:00
cvCheck . ProcessingStage = "Analyzing Timeline" ;
await _dbContext . SaveChangesAsync ( cancellationToken ) ;
2026-01-18 19:20:50 +01:00
var timelineAnalysis = _timelineAnalyserService . Analyse ( cvData . Employment ) ;
_logger . LogDebug (
"Timeline analysis for check {CheckId}: {GapCount} gaps, {OverlapCount} overlaps" ,
cvCheckId , timelineAnalysis . Gaps . Count , timelineAnalysis . Overlaps . Count ) ;
2026-01-20 16:45:43 +01:00
// Step 8: Calculate veracity score
2026-01-20 20:58:12 +01:00
cvCheck . ProcessingStage = "Calculating Score" ;
await _dbContext . SaveChangesAsync ( cancellationToken ) ;
2026-01-20 20:00:24 +01:00
var ( score , flags ) = CalculateVeracityScore ( verificationResults , educationResults , timelineAnalysis , cvData ) ;
2026-01-18 19:20:50 +01:00
_logger . LogDebug ( "Calculated veracity score for check {CheckId}: {Score}" , cvCheckId , score ) ;
2026-01-20 16:45:43 +01:00
// Step 9: Create CVFlag records
2026-01-18 19:20:50 +01:00
foreach ( var flag in flags )
{
2026-01-20 16:45:43 +01:00
if ( ! Enum . TryParse < FlagCategory > ( flag . Category , out var category ) )
{
_logger . LogWarning ( "Unknown flag category: {Category}, defaulting to Timeline" , flag . Category ) ;
category = FlagCategory . Timeline ;
}
if ( ! Enum . TryParse < FlagSeverity > ( flag . Severity , out var severity ) )
{
_logger . LogWarning ( "Unknown flag severity: {Severity}, defaulting to Info" , flag . Severity ) ;
severity = FlagSeverity . Info ;
}
2026-01-18 19:20:50 +01:00
var cvFlag = new CVFlag
{
Id = Guid . NewGuid ( ) ,
CVCheckId = cvCheckId ,
2026-01-20 16:45:43 +01:00
Category = category ,
Severity = severity ,
2026-01-18 19:20:50 +01:00
Title = flag . Title ,
Description = flag . Description ,
ScoreImpact = flag . ScoreImpact
} ;
_dbContext . CVFlags . Add ( cvFlag ) ;
}
2026-01-20 16:45:43 +01:00
// Step 10: Generate veracity report
2026-01-20 20:58:12 +01:00
cvCheck . ProcessingStage = "Generating Report" ;
await _dbContext . SaveChangesAsync ( cancellationToken ) ;
2026-01-18 19:20:50 +01:00
var report = new VeracityReport
{
OverallScore = score ,
ScoreLabel = GetScoreLabel ( score ) ,
EmploymentVerifications = verificationResults ,
2026-01-20 16:45:43 +01:00
EducationVerifications = educationResults ,
2026-01-18 19:20:50 +01:00
TimelineAnalysis = timelineAnalysis ,
Flags = flags ,
GeneratedAt = DateTime . UtcNow
} ;
2026-01-20 16:45:43 +01:00
cvCheck . ReportJson = JsonSerializer . Serialize ( report , JsonDefaults . CamelCaseIndented ) ;
2026-01-18 19:20:50 +01:00
cvCheck . VeracityScore = score ;
2026-01-20 16:45:43 +01:00
// Step 11: Update status to Completed
2026-01-18 19:20:50 +01:00
cvCheck . Status = CheckStatus . Completed ;
2026-01-20 20:58:12 +01:00
cvCheck . ProcessingStage = null ; // Clear stage on completion
2026-01-18 19:20:50 +01:00
cvCheck . CompletedAt = DateTime . UtcNow ;
await _dbContext . SaveChangesAsync ( cancellationToken ) ;
_logger . LogInformation (
"CV check {CheckId} completed successfully with score {Score}" ,
cvCheckId , score ) ;
2026-01-20 20:58:12 +01:00
await _auditService . LogAsync ( cvCheck . UserId , AuditActions . CVProcessed , "CVCheck" , cvCheckId , $"Score: {score}" ) ;
2026-01-18 19:20:50 +01:00
}
catch ( Exception ex )
{
_logger . LogError ( ex , "Error processing CV check {CheckId}" , cvCheckId ) ;
2026-01-20 20:58:12 +01:00
try
{
cvCheck . Status = CheckStatus . Failed ;
// Use CancellationToken.None to ensure failure status is saved even if original token is cancelled
await _dbContext . SaveChangesAsync ( CancellationToken . None ) ;
}
catch ( DbUpdateConcurrencyException )
{
// Record was deleted during processing - nothing to update
_logger . LogWarning ( "CV check {CheckId} was deleted during processing" , cvCheckId ) ;
return ;
}
2026-01-18 19:20:50 +01:00
throw ;
}
}
private static ( int Score , List < FlagResult > Flags ) CalculateVeracityScore (
List < CompanyVerificationResult > verifications ,
2026-01-20 16:45:43 +01:00
List < EducationVerificationResult > educationResults ,
2026-01-20 20:00:24 +01:00
TimelineAnalysisResult timeline ,
CVData cvData )
2026-01-18 19:20:50 +01:00
{
var score = BaseScore ;
var flags = new List < FlagResult > ( ) ;
2026-01-20 22:07:23 +01:00
// Penalty for unverified companies (deduplicated by company name)
var unverifiedByCompany = verifications
. Where ( v = > ! v . IsVerified )
. GroupBy ( v = > v . ClaimedCompany , StringComparer . OrdinalIgnoreCase )
. ToList ( ) ;
foreach ( var companyGroup in unverifiedByCompany )
2026-01-18 19:20:50 +01:00
{
score - = UnverifiedCompanyPenalty ;
2026-01-20 22:07:23 +01:00
var firstInstance = companyGroup . First ( ) ;
var instanceCount = companyGroup . Count ( ) ;
var description = instanceCount > 1
? $"Could not verify employment at '{firstInstance.ClaimedCompany}' ({instanceCount} roles). {firstInstance.VerificationNotes}"
: $"Could not verify employment at '{firstInstance.ClaimedCompany}'. {firstInstance.VerificationNotes}" ;
2026-01-18 19:20:50 +01:00
flags . Add ( new FlagResult
{
Category = FlagCategory . Employment . ToString ( ) ,
Severity = FlagSeverity . Warning . ToString ( ) ,
Title = "Unverified Company" ,
2026-01-20 22:07:23 +01:00
Description = description ,
2026-01-18 19:20:50 +01:00
ScoreImpact = - UnverifiedCompanyPenalty
} ) ;
}
2026-01-20 20:00:24 +01:00
// Process company verification flags (incorporation date, dissolution, dormant, etc.)
2026-01-20 22:07:23 +01:00
// Deduplicate by (company, flag type) to avoid penalizing same issue multiple times
var processedCompanyFlags = new HashSet < ( string Company , string FlagType ) > (
new CompanyFlagComparer ( ) ) ;
2026-01-20 20:00:24 +01:00
foreach ( var verification in verifications . Where ( v = > v . Flags . Count > 0 ) )
{
foreach ( var companyFlag in verification . Flags )
{
2026-01-20 22:07:23 +01:00
var key = ( verification . ClaimedCompany , companyFlag . Type ) ;
if ( ! processedCompanyFlags . Add ( key ) )
{
// Already processed this flag for this company, skip
continue ;
}
2026-01-20 20:00:24 +01:00
var penalty = Math . Abs ( companyFlag . ScoreImpact ) ;
score - = penalty ;
var severity = companyFlag . Severity switch
{
"Critical" = > FlagSeverity . Critical ,
"Warning" = > FlagSeverity . Warning ,
_ = > FlagSeverity . Info
} ;
flags . Add ( new FlagResult
{
Category = FlagCategory . Employment . ToString ( ) ,
Severity = severity . ToString ( ) ,
Title = companyFlag . Type switch
{
"EmploymentBeforeIncorporation" = > "Employment Before Company Existed" ,
"EmploymentAtDissolvedCompany" = > "Employment at Dissolved Company" ,
"CurrentEmploymentAtDissolvedCompany" = > "Current Employment at Dissolved Company" ,
"EmploymentAtDormantCompany" = > "Employment at Dormant Company" ,
"SeniorRoleAtMicroCompany" = > "Senior Role at Micro Company" ,
"SicCodeMismatch" = > "Role/Industry Mismatch" ,
"ImplausibleJobTitle" = > "Implausible Job Title" ,
2026-01-20 21:04:30 +01:00
"UnverifiedDirectorClaim" = > "Unverified Director Claim" ,
2026-01-20 20:00:24 +01:00
_ = > companyFlag . Type
} ,
Description = companyFlag . Message ,
ScoreImpact = - penalty
} ) ;
}
}
// Check for rapid career progression
CheckRapidCareerProgression ( cvData . Employment , flags , ref score ) ;
// Check for early career senior roles (relative to education end date)
CheckEarlyCareerSeniorRoles ( cvData . Employment , cvData . Education , flags , ref score ) ;
2026-01-20 23:00:07 +01:00
// Check for frequent job changes (informational only)
CheckFrequentJobChanges ( cvData . Employment , flags ) ;
2026-01-20 16:45:43 +01:00
// Penalty for diploma mills (critical)
foreach ( var edu in educationResults . Where ( e = > e . IsDiplomaMill ) )
{
score - = DiplomaMillPenalty ;
flags . Add ( new FlagResult
{
Category = FlagCategory . Education . ToString ( ) ,
Severity = FlagSeverity . Critical . ToString ( ) ,
Title = "Diploma Mill Detected" ,
Description = $"'{edu.ClaimedInstitution}' is a known diploma mill. {edu.VerificationNotes}" ,
ScoreImpact = - DiplomaMillPenalty
} ) ;
}
// Penalty for suspicious institutions
foreach ( var edu in educationResults . Where ( e = > e . IsSuspicious & & ! e . IsDiplomaMill ) )
{
score - = SuspiciousInstitutionPenalty ;
flags . Add ( new FlagResult
{
Category = FlagCategory . Education . ToString ( ) ,
Severity = FlagSeverity . Warning . ToString ( ) ,
Title = "Suspicious Institution" ,
Description = $"'{edu.ClaimedInstitution}' has suspicious characteristics. {edu.VerificationNotes}" ,
ScoreImpact = - SuspiciousInstitutionPenalty
} ) ;
}
// Penalty for unverified education (not recognised, but not flagged as fake)
foreach ( var edu in educationResults . Where ( e = > ! e . IsVerified & & ! e . IsDiplomaMill & & ! e . IsSuspicious & & e . Status = = "Unknown" ) )
{
score - = UnverifiedEducationPenalty ;
flags . Add ( new FlagResult
{
Category = FlagCategory . Education . ToString ( ) ,
Severity = FlagSeverity . Info . ToString ( ) ,
Title = "Unverified Institution" ,
Description = $"Could not verify '{edu.ClaimedInstitution}'. {edu.VerificationNotes}" ,
ScoreImpact = - UnverifiedEducationPenalty
} ) ;
}
// Penalty for implausible education dates
foreach ( var edu in educationResults . Where ( e = > ! e . DatesArePlausible ) )
{
score - = EducationDatePenalty ;
flags . Add ( new FlagResult
{
Category = FlagCategory . Education . ToString ( ) ,
Severity = FlagSeverity . Warning . ToString ( ) ,
Title = "Education Date Issues" ,
Description = $"Date issues for '{edu.ClaimedInstitution}': {edu.DatePlausibilityNotes}" ,
ScoreImpact = - EducationDatePenalty
} ) ;
}
2026-01-18 19:20:50 +01:00
// 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
} ) ;
}
2026-01-20 21:06:06 +01:00
// Note overlaps - these are often legitimate (part-time, consulting, transitions)
// Only flag as informational, no score penalty
2026-01-18 19:20:50 +01:00
foreach ( var overlap in timeline . Overlaps )
{
flags . Add ( new FlagResult
{
Category = FlagCategory . Timeline . ToString ( ) ,
2026-01-20 21:06:06 +01:00
Severity = FlagSeverity . Info . ToString ( ) ,
Title = "Concurrent Employment" ,
Description = $"Worked at both '{overlap.Company1}' and '{overlap.Company2}' simultaneously for {overlap.Months} months ({overlap.OverlapStart:MMM yyyy} to {overlap.OverlapEnd:MMM yyyy})" ,
ScoreImpact = 0
2026-01-18 19:20:50 +01:00
} ) ;
}
2026-01-20 20:58:12 +01:00
// Deduplicate flags based on Title + Description
var uniqueFlags = flags
. GroupBy ( f = > ( f . Title , f . Description ) )
. Select ( g = > g . First ( ) )
. ToList ( ) ;
// Recalculate score based on unique flags
var uniqueScore = BaseScore + uniqueFlags . Sum ( f = > f . ScoreImpact ) ;
2026-01-18 19:20:50 +01:00
// Ensure score doesn't go below 0
2026-01-20 20:58:12 +01:00
uniqueScore = Math . Max ( 0 , uniqueScore ) ;
2026-01-18 19:20:50 +01:00
2026-01-20 20:58:12 +01:00
return ( uniqueScore , uniqueFlags ) ;
2026-01-18 19:20:50 +01:00
}
private static string GetScoreLabel ( int score )
{
return score switch
{
> = 90 = > "Excellent" ,
> = 75 = > "Good" ,
> = 60 = > "Fair" ,
> = 40 = > "Poor" ,
_ = > "Very Poor"
} ;
}
2026-01-20 20:00:24 +01:00
private static bool IsFreelance ( string companyName )
{
if ( string . IsNullOrWhiteSpace ( companyName ) ) return false ;
var name = companyName . Trim ( ) . ToLowerInvariant ( ) ;
return name = = "freelance" | |
name = = "freelancer" | |
name = = "self-employed" | |
name = = "self employed" | |
name . StartsWith ( "freelance " ) | |
name . StartsWith ( "self-employed " ) | |
name . Contains ( "(freelance)" ) | |
name . Contains ( "(self-employed)" ) ;
}
private async Task VerifyDirectorClaims (
string candidateName ,
List < CompanyVerificationResult > verificationResults ,
CancellationToken cancellationToken )
{
2026-01-20 20:58:12 +01:00
// Find all director claims at verified companies - use ToList() to avoid modifying during enumeration
var directorCandidates = verificationResults
. Select ( ( result , index ) = > ( result , index ) )
. Where ( x = > x . result . IsVerified & & ! string . IsNullOrEmpty ( x . result . MatchedCompanyNumber ) )
. ToList ( ) ;
foreach ( var ( result , index ) in directorCandidates )
2026-01-20 20:00:24 +01:00
{
var jobTitle = result . ClaimedJobTitle ? . ToLowerInvariant ( ) ? ? "" ;
// Check if this is a director claim
var isDirectorClaim = jobTitle . Contains ( "director" ) | |
jobTitle . Contains ( "company secretary" ) | |
jobTitle = = "md" | |
jobTitle . Contains ( "managing director" ) ;
if ( ! isDirectorClaim ) continue ;
_logger . LogDebug (
"Verifying director claim for {Candidate} at {Company}" ,
candidateName , result . MatchedCompanyName ) ;
var isVerifiedDirector = await _companyVerifierService . VerifyDirectorAsync (
result . MatchedCompanyNumber ! ,
candidateName ,
result . ClaimedStartDate ,
result . ClaimedEndDate ) ;
if ( isVerifiedDirector = = false )
{
// Add a flag for unverified director claim
2026-01-20 20:58:12 +01:00
var flags = ( result . Flags ? ? [ ] ) . ToList ( ) ;
2026-01-20 20:00:24 +01:00
flags . Add ( new CompanyVerificationFlag
{
Type = "UnverifiedDirectorClaim" ,
Severity = "Critical" ,
Message = $"Claimed director role at '{result.MatchedCompanyName}' but candidate name not found in Companies House officers list" ,
ScoreImpact = - 20
} ) ;
// Update the result with the new flag
verificationResults [ index ] = result with { Flags = flags } ;
_logger . LogWarning (
"Director claim not verified for {Candidate} at {Company}" ,
candidateName , result . MatchedCompanyName ) ;
}
else if ( isVerifiedDirector = = true )
{
_logger . LogInformation (
"Director claim verified for {Candidate} at {Company}" ,
candidateName , result . MatchedCompanyName ) ;
}
}
}
private static void CheckRapidCareerProgression (
List < EmploymentEntry > employment ,
List < FlagResult > flags ,
ref int score )
{
// Group employment by company and check for rapid promotions
var byCompany = employment
. Where ( e = > ! string . IsNullOrWhiteSpace ( e . CompanyName ) & & e . StartDate . HasValue )
. GroupBy ( e = > e . CompanyName . ToLowerInvariant ( ) )
. Where ( g = > g . Count ( ) > 1 ) ;
foreach ( var companyGroup in byCompany )
{
var orderedRoles = companyGroup . OrderBy ( e = > e . StartDate ) . ToList ( ) ;
for ( int i = 1 ; i < orderedRoles . Count ; i + + )
{
var prevRole = orderedRoles [ i - 1 ] ;
var currRole = orderedRoles [ i ] ;
var prevSeniority = GetSeniorityLevel ( prevRole . JobTitle ) ;
var currSeniority = GetSeniorityLevel ( currRole . JobTitle ) ;
// Check for jump of 3+ seniority levels
var seniorityJump = currSeniority - prevSeniority ;
if ( seniorityJump > = 3 )
{
// Calculate time between roles
var monthsBetween = ( ( currRole . StartDate ! . Value . Year - prevRole . StartDate ! . Value . Year ) * 12 ) +
( currRole . StartDate ! . Value . Month - prevRole . StartDate ! . Value . Month ) ;
// If jumped 3+ levels in less than 2 years, flag it
if ( monthsBetween < 24 )
{
score - = RapidProgressionPenalty ;
flags . Add ( new FlagResult
{
Category = FlagCategory . Employment . ToString ( ) ,
Severity = FlagSeverity . Warning . ToString ( ) ,
Title = "Rapid Career Progression" ,
Description = $"Promoted from '{prevRole.JobTitle}' to '{currRole.JobTitle}' at '{companyGroup.First().CompanyName}' in {monthsBetween} months - unusually fast progression" ,
ScoreImpact = - RapidProgressionPenalty
} ) ;
}
}
}
}
}
private static void CheckEarlyCareerSeniorRoles (
List < EmploymentEntry > employment ,
List < EducationEntry > education ,
List < FlagResult > flags ,
ref int score )
{
// Find the latest education end date to estimate career start
var latestEducationEnd = education
. Where ( e = > e . EndDate . HasValue )
. Select ( e = > e . EndDate ! . Value )
. DefaultIfEmpty ( DateOnly . MinValue )
. Max ( ) ;
if ( latestEducationEnd = = DateOnly . MinValue )
{
// No education dates available, skip check
return ;
}
foreach ( var emp in employment . Where ( e = > e . StartDate . HasValue ) )
{
var monthsAfterEducation = ( ( emp . StartDate ! . Value . Year - latestEducationEnd . Year ) * 12 ) +
( emp . StartDate ! . Value . Month - latestEducationEnd . Month ) ;
// Check if this is a senior role started within 2 years of finishing education
if ( monthsAfterEducation < 24 & & monthsAfterEducation > = 0 )
{
var seniority = GetSeniorityLevel ( emp . JobTitle ) ;
// Flag if they're claiming a senior role (level 4+) very early in career
if ( seniority > = 4 )
{
score - = EarlyCareerSeniorRolePenalty ;
flags . Add ( new FlagResult
{
Category = FlagCategory . Employment . ToString ( ) ,
Severity = FlagSeverity . Warning . ToString ( ) ,
Title = "Early Career Senior Role" ,
Description = $"Claimed senior role '{emp.JobTitle}' at '{emp.CompanyName}' only {monthsAfterEducation} months after completing education" ,
ScoreImpact = - EarlyCareerSeniorRolePenalty
} ) ;
}
}
}
}
2026-01-20 23:00:07 +01:00
private const int ShortTenureMonths = 18 ;
private const int MinShortTenuresForFlag = 3 ;
private static void CheckFrequentJobChanges (
List < EmploymentEntry > employment ,
List < FlagResult > flags )
{
// Group employment by normalized company name (to combine roles at same employer)
var employerTenures = employment
. Where ( e = > ! string . IsNullOrWhiteSpace ( e . CompanyName ) & & e . StartDate . HasValue )
. Where ( e = > ! IsFreelance ( e . CompanyName ) ) // Exclude freelance
. GroupBy ( e = > NormalizeCompanyForGrouping ( e . CompanyName ) )
. Select ( g = >
{
// Calculate total tenure at this employer (sum of all roles)
var totalMonths = 0 ;
foreach ( var role in g )
{
if ( role . StartDate . HasValue )
{
var endDate = role . EndDate ? ? DateOnly . FromDateTime ( DateTime . Today ) ;
var months = ( ( endDate . Year - role . StartDate . Value . Year ) * 12 ) +
( endDate . Month - role . StartDate . Value . Month ) ;
totalMonths + = Math . Max ( 0 , months ) ;
}
}
return new
{
CompanyGroup = g . Key ,
DisplayName = g . First ( ) . CompanyName ,
TotalMonths = totalMonths ,
RoleCount = g . Count ( )
} ;
} )
. Where ( t = > t . TotalMonths > 0 ) // Exclude zero-tenure entries
. ToList ( ) ;
if ( employerTenures . Count = = 0 ) return ;
// Find short tenures (less than 18 months) at different companies
var shortTenures = employerTenures
. Where ( t = > t . TotalMonths < ShortTenureMonths )
. ToList ( ) ;
// Calculate average tenure across unique employers
var avgTenureMonths = employerTenures . Average ( t = > t . TotalMonths ) ;
var avgTenureYears = avgTenureMonths / 12.0 ;
// If 3+ different companies with short tenure, flag it (informational only)
if ( shortTenures . Count > = MinShortTenuresForFlag )
{
var shortTenureCompanies = string . Join ( ", " , shortTenures . Take ( 5 ) . Select ( t = > $"{t.DisplayName} ({t.TotalMonths}mo)" ) ) ;
var moreCount = shortTenures . Count > 5 ? $" and {shortTenures.Count - 5} more" : "" ;
flags . Add ( new FlagResult
{
Category = FlagCategory . Timeline . ToString ( ) ,
Severity = FlagSeverity . Info . ToString ( ) ,
Title = "Frequent Job Changes" ,
Description = $"Candidate has {shortTenures.Count} employers with tenure under {ShortTenureMonths} months: {shortTenureCompanies}{moreCount}. Average tenure: {avgTenureYears:F1} years across {employerTenures.Count} employers." ,
ScoreImpact = 0 // Informational only, no penalty
} ) ;
}
// Even without frequent changes, note average tenure if it's low
else if ( avgTenureMonths < 24 & & employerTenures . Count > = 3 )
{
flags . Add ( new FlagResult
{
Category = FlagCategory . Timeline . ToString ( ) ,
Severity = FlagSeverity . Info . ToString ( ) ,
Title = "Average Tenure" ,
Description = $"Average tenure: {avgTenureYears:F1} years across {employerTenures.Count} employers." ,
ScoreImpact = 0 // Informational only
} ) ;
}
}
/// <summary>
/// Normalizes company name for grouping purposes.
/// Groups companies like "BMW UK", "BMW Group", "BMW (UK) Ltd" together.
/// </summary>
private static string NormalizeCompanyForGrouping ( string companyName )
{
if ( string . IsNullOrWhiteSpace ( companyName ) ) return "" ;
var name = companyName . ToLowerInvariant ( ) . Trim ( ) ;
// Remove common suffixes
var suffixes = new [ ] { " limited" , " ltd" , " plc" , " llp" , " inc" , " corporation" , " corp" ,
" uk" , " (uk)" , " u.k." , " group" , " holdings" , " services" } ;
foreach ( var suffix in suffixes )
{
if ( name . EndsWith ( suffix ) )
{
name = name [ . . ^ suffix . Length ] . Trim ( ) ;
}
}
// Remove parenthetical content
name = System . Text . RegularExpressions . Regex . Replace ( name , @"\([^)]*\)" , "" ) . Trim ( ) ;
// Take first significant word(s) as the company identifier
// This helps group "Unilever Bestfood" with "Unilever UK"
var words = name . Split ( ' ' , StringSplitOptions . RemoveEmptyEntries ) ;
if ( words . Length > = 1 )
{
// Use first word if it's substantial (4+ chars), or first two words
if ( words [ 0 ] . Length > = 4 )
{
return words [ 0 ] ;
}
else if ( words . Length > = 2 )
{
return words [ 0 ] + " " + words [ 1 ] ;
}
}
return name ;
}
2026-01-20 20:00:24 +01:00
private static int GetSeniorityLevel ( string? jobTitle )
{
if ( string . IsNullOrWhiteSpace ( jobTitle ) ) return 0 ;
var title = jobTitle . ToLowerInvariant ( ) ;
// Level 6: C-suite
if ( title . Contains ( "ceo" ) | | title . Contains ( "cto" ) | | title . Contains ( "cfo" ) | |
title . Contains ( "coo" ) | | title . Contains ( "cio" ) | | title . Contains ( "chief" ) | |
title . Contains ( "managing director" ) | | title = = "md" | |
title . Contains ( "president" ) | | title . Contains ( "chairman" ) | |
title . Contains ( "chairwoman" ) | | title . Contains ( "chairperson" ) )
{
return 6 ;
}
// Level 5: VP / Executive
if ( title . Contains ( "vice president" ) | | title . Contains ( "vp " ) | |
title . StartsWith ( "vp" ) | | title . Contains ( "svp" ) | |
title . Contains ( "executive director" ) | | title . Contains ( "executive vice" ) )
{
return 5 ;
}
// Level 4: Director / Head
if ( title . Contains ( "director" ) | | title . Contains ( "head of" ) )
{
return 4 ;
}
// Level 3: Senior / Lead / Principal / Manager
if ( title . Contains ( "senior" ) | | title . Contains ( "lead" ) | |
title . Contains ( "principal" ) | | title . Contains ( "manager" ) | |
title . Contains ( "team lead" ) | | title . Contains ( "staff" ) )
{
return 3 ;
}
// Level 2: Mid-level (no junior, no senior)
if ( ! title . Contains ( "junior" ) & & ! title . Contains ( "trainee" ) & &
! title . Contains ( "intern" ) & & ! title . Contains ( "graduate" ) & &
! title . Contains ( "entry" ) & & ! title . Contains ( "assistant" ) )
{
return 2 ;
}
// Level 1: Junior / Entry-level
return 1 ;
}
2026-01-20 22:07:23 +01:00
/// <summary>
/// Comparer for deduplicating company flags by (company name, flag type).
/// Uses case-insensitive comparison for company names.
/// </summary>
private sealed class CompanyFlagComparer : IEqualityComparer < ( string Company , string FlagType ) >
{
public bool Equals ( ( string Company , string FlagType ) x , ( string Company , string FlagType ) y )
{
return string . Equals ( x . Company , y . Company , StringComparison . OrdinalIgnoreCase ) & &
string . Equals ( x . FlagType , y . FlagType , StringComparison . OrdinalIgnoreCase ) ;
}
public int GetHashCode ( ( string Company , string FlagType ) obj )
{
return HashCode . Combine (
obj . Company ? . ToUpperInvariant ( ) ? ? "" ,
obj . FlagType ? . ToUpperInvariant ( ) ? ? "" ) ;
}
}
2026-01-18 19:20:50 +01:00
}