Add comprehensive CV verification checks and dashboard auto-refresh
- Add dashboard auto-refresh polling to update when processing completes - Skip verification for freelance employers (but not contractors) - Add incorporation date check (flags employment before company existed) - Add dissolution date check (flags employment at dissolved companies) - Add dormant company check (flags non-director roles at dormant companies) - Add company size vs role check (flags senior roles at micro-entities) - Add SIC code mismatch check (flags tech roles at non-tech companies) - Add director verification against Companies House officers - Add rapid career progression detection (3+ seniority jumps in <2 years) - Add early career senior role detection (<2 years after education) - Extend CompanyVerificationResult with flags and company data - Add officers endpoint to Companies House client - Fix null reference warning in Report.razor Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,10 @@ public sealed class ProcessCVCheckJob
|
||||
|
||||
private const int BaseScore = 100;
|
||||
private const int UnverifiedCompanyPenalty = 10;
|
||||
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;
|
||||
private const int GapMonthPenalty = 1;
|
||||
private const int MaxGapPenalty = 10;
|
||||
private const int OverlapMonthPenalty = 2;
|
||||
@@ -86,22 +90,46 @@ public sealed class ProcessCVCheckJob
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Step 5: Verify each employment entry (parallelized with rate limiting)
|
||||
var verificationTasks = cvData.Employment.Select(async employment =>
|
||||
{
|
||||
var result = await _companyVerifierService.VerifyCompanyAsync(
|
||||
employment.CompanyName,
|
||||
employment.StartDate,
|
||||
employment.EndDate);
|
||||
// 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);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Verified {Company}: {IsVerified} (Score: {Score}%)",
|
||||
employment.CompanyName, result.IsVerified, result.MatchScore);
|
||||
_logger.LogDebug(
|
||||
"Verified {Company}: {IsVerified} (Score: {Score}%), JobTitle: {JobTitle}, Plausible: {Plausible}",
|
||||
employment.CompanyName, result.IsVerified, result.MatchScore,
|
||||
employment.JobTitle, result.JobTitlePlausible);
|
||||
|
||||
return result;
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
var verificationResults = (await Task.WhenAll(verificationTasks)).ToList();
|
||||
|
||||
// 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
|
||||
await VerifyDirectorClaims(cvData.FullName, verificationResults, cancellationToken);
|
||||
|
||||
// Step 6: Verify education entries
|
||||
var educationResults = _educationVerifierService.VerifyAll(
|
||||
cvData.Education,
|
||||
@@ -122,7 +150,7 @@ public sealed class ProcessCVCheckJob
|
||||
cvCheckId, timelineAnalysis.Gaps.Count, timelineAnalysis.Overlaps.Count);
|
||||
|
||||
// Step 8: Calculate veracity score
|
||||
var (score, flags) = CalculateVeracityScore(verificationResults, educationResults, timelineAnalysis);
|
||||
var (score, flags) = CalculateVeracityScore(verificationResults, educationResults, timelineAnalysis, cvData);
|
||||
|
||||
_logger.LogDebug("Calculated veracity score for check {CheckId}: {Score}", cvCheckId, score);
|
||||
|
||||
@@ -194,7 +222,8 @@ public sealed class ProcessCVCheckJob
|
||||
private static (int Score, List<FlagResult> Flags) CalculateVeracityScore(
|
||||
List<CompanyVerificationResult> verifications,
|
||||
List<EducationVerificationResult> educationResults,
|
||||
TimelineAnalysisResult timeline)
|
||||
TimelineAnalysisResult timeline,
|
||||
CVData cvData)
|
||||
{
|
||||
var score = BaseScore;
|
||||
var flags = new List<FlagResult>();
|
||||
@@ -214,6 +243,48 @@ public sealed class ProcessCVCheckJob
|
||||
});
|
||||
}
|
||||
|
||||
// Process company verification flags (incorporation date, dissolution, dormant, etc.)
|
||||
foreach (var verification in verifications.Where(v => v.Flags.Count > 0))
|
||||
{
|
||||
foreach (var companyFlag in verification.Flags)
|
||||
{
|
||||
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",
|
||||
_ => 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);
|
||||
|
||||
// Penalty for diploma mills (critical)
|
||||
foreach (var edu in educationResults.Where(e => e.IsDiplomaMill))
|
||||
{
|
||||
@@ -328,4 +399,223 @@ public sealed class ProcessCVCheckJob
|
||||
_ => "Very Poor"
|
||||
};
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
// Find all director claims at verified companies
|
||||
foreach (var result in verificationResults.Where(v => v.IsVerified && !string.IsNullOrEmpty(v.MatchedCompanyNumber)))
|
||||
{
|
||||
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
|
||||
var flags = result.Flags.ToList();
|
||||
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
|
||||
var index = verificationResults.IndexOf(result);
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user