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:
2026-01-20 20:00:24 +01:00
parent acf4d96fae
commit 652aa2e612
9 changed files with 937 additions and 36 deletions

View File

@@ -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;
}
}