Add comprehensive informational flags for recruiters
New info flags (no penalty - provide context): - Career Span: Total years of experience with label (Early Career to Extensive) - Current Status: Currently employed at X / Available since Y - Long Tenure: Highlight 5+ year roles positively - Management Experience: X of Y roles with management titles - Individual Contributor: When no management titles detected - Company Size Pattern: Startup/SME/Corporate experience breakdown - Career Trajectory: Upward/Lateral/Step-down progression analysis - PLC Experience: Roles at publicly listed companies - Director Experience: Director/officer roles without red flags All flags have ScoreImpact = 0 (informational only) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -344,6 +344,16 @@ public sealed class ProcessCVCheckJob
|
|||||||
// Check for frequent job changes (informational only)
|
// Check for frequent job changes (informational only)
|
||||||
CheckFrequentJobChanges(cvData.Employment, flags);
|
CheckFrequentJobChanges(cvData.Employment, flags);
|
||||||
|
|
||||||
|
// Informational flags (no penalty - provide context for recruiters)
|
||||||
|
AddCareerSpanFlag(cvData.Employment, flags);
|
||||||
|
AddCurrentEmploymentStatusFlag(cvData.Employment, flags);
|
||||||
|
AddLongTenureFlags(cvData.Employment, flags);
|
||||||
|
AddManagementExperienceFlag(cvData.Employment, flags);
|
||||||
|
AddCompanySizePatternFlag(verifications, flags);
|
||||||
|
AddCareerTrajectoryFlag(cvData.Employment, flags);
|
||||||
|
AddPLCExperienceFlag(verifications, flags);
|
||||||
|
AddVerifiedDirectorFlag(verifications, flags);
|
||||||
|
|
||||||
// Penalty for diploma mills (critical)
|
// Penalty for diploma mills (critical)
|
||||||
foreach (var edu in educationResults.Where(e => e.IsDiplomaMill))
|
foreach (var edu in educationResults.Where(e => e.IsDiplomaMill))
|
||||||
{
|
{
|
||||||
@@ -757,6 +767,378 @@ public sealed class ProcessCVCheckJob
|
|||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#region Informational Flags (No Penalty)
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a flag showing total career span from earliest to latest employment.
|
||||||
|
/// </summary>
|
||||||
|
private static void AddCareerSpanFlag(List<EmploymentEntry> employment, List<FlagResult> flags)
|
||||||
|
{
|
||||||
|
var datedEmployment = employment
|
||||||
|
.Where(e => e.StartDate.HasValue)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (datedEmployment.Count == 0) return;
|
||||||
|
|
||||||
|
var earliestStart = datedEmployment.Min(e => e.StartDate!.Value);
|
||||||
|
var latestEnd = datedEmployment
|
||||||
|
.Select(e => e.EndDate ?? DateOnly.FromDateTime(DateTime.Today))
|
||||||
|
.Max();
|
||||||
|
|
||||||
|
var totalMonths = ((latestEnd.Year - earliestStart.Year) * 12) + (latestEnd.Month - earliestStart.Month);
|
||||||
|
var years = totalMonths / 12;
|
||||||
|
var months = totalMonths % 12;
|
||||||
|
|
||||||
|
var spanText = years > 0
|
||||||
|
? (months > 0 ? $"{years} years {months} months" : $"{years} years")
|
||||||
|
: $"{months} months";
|
||||||
|
|
||||||
|
var label = years switch
|
||||||
|
{
|
||||||
|
>= 20 => "Extensive Career",
|
||||||
|
>= 10 => "Established Professional",
|
||||||
|
>= 5 => "Experienced",
|
||||||
|
>= 2 => "Early-Mid Career",
|
||||||
|
_ => "Early Career"
|
||||||
|
};
|
||||||
|
|
||||||
|
flags.Add(new FlagResult
|
||||||
|
{
|
||||||
|
Category = FlagCategory.Timeline.ToString(),
|
||||||
|
Severity = FlagSeverity.Info.ToString(),
|
||||||
|
Title = "Career Span",
|
||||||
|
Description = $"{label}: {spanText} of professional experience ({earliestStart:MMM yyyy} to {(latestEnd == DateOnly.FromDateTime(DateTime.Today) ? "present" : latestEnd.ToString("MMM yyyy"))})",
|
||||||
|
ScoreImpact = 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a flag showing current employment status.
|
||||||
|
/// </summary>
|
||||||
|
private static void AddCurrentEmploymentStatusFlag(List<EmploymentEntry> employment, List<FlagResult> flags)
|
||||||
|
{
|
||||||
|
var currentRole = employment.FirstOrDefault(e => e.IsCurrent || !e.EndDate.HasValue);
|
||||||
|
|
||||||
|
if (currentRole != null)
|
||||||
|
{
|
||||||
|
var startText = currentRole.StartDate.HasValue
|
||||||
|
? $" since {currentRole.StartDate.Value:MMM yyyy}"
|
||||||
|
: "";
|
||||||
|
|
||||||
|
flags.Add(new FlagResult
|
||||||
|
{
|
||||||
|
Category = FlagCategory.Employment.ToString(),
|
||||||
|
Severity = FlagSeverity.Info.ToString(),
|
||||||
|
Title = "Current Status",
|
||||||
|
Description = $"Currently employed at {currentRole.CompanyName}{startText}",
|
||||||
|
ScoreImpact = 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var lastRole = employment
|
||||||
|
.Where(e => e.EndDate.HasValue)
|
||||||
|
.OrderByDescending(e => e.EndDate)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (lastRole?.EndDate != null)
|
||||||
|
{
|
||||||
|
var monthsSince = ((DateTime.Today.Year - lastRole.EndDate.Value.Year) * 12) +
|
||||||
|
(DateTime.Today.Month - lastRole.EndDate.Value.Month);
|
||||||
|
|
||||||
|
if (monthsSince > 0)
|
||||||
|
{
|
||||||
|
flags.Add(new FlagResult
|
||||||
|
{
|
||||||
|
Category = FlagCategory.Employment.ToString(),
|
||||||
|
Severity = FlagSeverity.Info.ToString(),
|
||||||
|
Title = "Current Status",
|
||||||
|
Description = $"Currently available - last role ended {lastRole.EndDate.Value:MMM yyyy} ({monthsSince} months ago)",
|
||||||
|
ScoreImpact = 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Highlights roles with particularly long tenure (5+ years).
|
||||||
|
/// </summary>
|
||||||
|
private static void AddLongTenureFlags(List<EmploymentEntry> employment, List<FlagResult> flags)
|
||||||
|
{
|
||||||
|
const int longTenureMonths = 60; // 5 years
|
||||||
|
|
||||||
|
var longTenures = employment
|
||||||
|
.Where(e => e.StartDate.HasValue)
|
||||||
|
.Select(e =>
|
||||||
|
{
|
||||||
|
var endDate = e.EndDate ?? DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var months = ((endDate.Year - e.StartDate!.Value.Year) * 12) + (endDate.Month - e.StartDate.Value.Month);
|
||||||
|
return new { Entry = e, Months = months };
|
||||||
|
})
|
||||||
|
.Where(x => x.Months >= longTenureMonths)
|
||||||
|
.OrderByDescending(x => x.Months)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (longTenures.Count > 0)
|
||||||
|
{
|
||||||
|
var longest = longTenures[0];
|
||||||
|
var years = longest.Months / 12;
|
||||||
|
|
||||||
|
if (longTenures.Count == 1)
|
||||||
|
{
|
||||||
|
flags.Add(new FlagResult
|
||||||
|
{
|
||||||
|
Category = FlagCategory.Employment.ToString(),
|
||||||
|
Severity = FlagSeverity.Info.ToString(),
|
||||||
|
Title = "Long Tenure",
|
||||||
|
Description = $"{years} years at {longest.Entry.CompanyName} - demonstrates commitment and stability",
|
||||||
|
ScoreImpact = 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var companies = string.Join(", ", longTenures.Take(3).Select(t => $"{t.Entry.CompanyName} ({t.Months / 12}y)"));
|
||||||
|
flags.Add(new FlagResult
|
||||||
|
{
|
||||||
|
Category = FlagCategory.Employment.ToString(),
|
||||||
|
Severity = FlagSeverity.Info.ToString(),
|
||||||
|
Title = "Long Tenure",
|
||||||
|
Description = $"{longTenures.Count} roles with 5+ year tenure: {companies}",
|
||||||
|
ScoreImpact = 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indicates management vs individual contributor experience.
|
||||||
|
/// </summary>
|
||||||
|
private static void AddManagementExperienceFlag(List<EmploymentEntry> employment, List<FlagResult> flags)
|
||||||
|
{
|
||||||
|
var managementKeywords = new[] { "manager", "head of", "director", "lead", "team lead", "supervisor", "chief", "vp", "vice president" };
|
||||||
|
|
||||||
|
var managementRoles = employment
|
||||||
|
.Where(e => !string.IsNullOrWhiteSpace(e.JobTitle))
|
||||||
|
.Where(e => managementKeywords.Any(kw => e.JobTitle!.ToLowerInvariant().Contains(kw)))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var totalRoles = employment.Count(e => !string.IsNullOrWhiteSpace(e.JobTitle));
|
||||||
|
if (totalRoles == 0) return;
|
||||||
|
|
||||||
|
if (managementRoles.Count > 0)
|
||||||
|
{
|
||||||
|
var recentManagement = managementRoles
|
||||||
|
.OrderByDescending(e => e.StartDate ?? DateOnly.MinValue)
|
||||||
|
.Take(2)
|
||||||
|
.Select(e => e.JobTitle)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
flags.Add(new FlagResult
|
||||||
|
{
|
||||||
|
Category = FlagCategory.Employment.ToString(),
|
||||||
|
Severity = FlagSeverity.Info.ToString(),
|
||||||
|
Title = "Management Experience",
|
||||||
|
Description = $"{managementRoles.Count} of {totalRoles} roles include management responsibility. Recent: {string.Join(", ", recentManagement)}",
|
||||||
|
ScoreImpact = 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
flags.Add(new FlagResult
|
||||||
|
{
|
||||||
|
Category = FlagCategory.Employment.ToString(),
|
||||||
|
Severity = FlagSeverity.Info.ToString(),
|
||||||
|
Title = "Individual Contributor",
|
||||||
|
Description = "All roles appear to be individual contributor positions - no management titles detected",
|
||||||
|
ScoreImpact = 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shows pattern of company sizes (startup/SME/corporate).
|
||||||
|
/// </summary>
|
||||||
|
private static void AddCompanySizePatternFlag(List<CompanyVerificationResult> verifications, List<FlagResult> flags)
|
||||||
|
{
|
||||||
|
var verifiedWithAccounts = verifications
|
||||||
|
.Where(v => v.IsVerified && !string.IsNullOrWhiteSpace(v.AccountsCategory))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (verifiedWithAccounts.Count < 2) return;
|
||||||
|
|
||||||
|
var sizeGroups = verifiedWithAccounts
|
||||||
|
.GroupBy(v => v.AccountsCategory?.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"micro-entity" or "micro" => "Micro/Startup",
|
||||||
|
"small" => "Small",
|
||||||
|
"medium" or "audit-exempt-subsidiary" => "Medium",
|
||||||
|
"full" or "group" or "dormant" => "Large",
|
||||||
|
_ => "Unknown"
|
||||||
|
})
|
||||||
|
.Where(g => g.Key != "Unknown")
|
||||||
|
.ToDictionary(g => g.Key, g => g.Count());
|
||||||
|
|
||||||
|
if (sizeGroups.Count == 0) return;
|
||||||
|
|
||||||
|
var dominant = sizeGroups.OrderByDescending(kv => kv.Value).First();
|
||||||
|
var total = sizeGroups.Values.Sum();
|
||||||
|
var percentage = (dominant.Value * 100) / total;
|
||||||
|
|
||||||
|
string pattern;
|
||||||
|
if (percentage >= 70)
|
||||||
|
{
|
||||||
|
pattern = dominant.Key switch
|
||||||
|
{
|
||||||
|
"Micro/Startup" => "Startup/Early-stage focus",
|
||||||
|
"Small" => "Small business specialist",
|
||||||
|
"Medium" => "SME experience",
|
||||||
|
"Large" => "Large corporate experience",
|
||||||
|
_ => "Mixed company sizes"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else if (sizeGroups.Count >= 3)
|
||||||
|
{
|
||||||
|
pattern = "Diverse company sizes - experience across startups to corporates";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
pattern = $"Mix of {string.Join(" and ", sizeGroups.Keys)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
flags.Add(new FlagResult
|
||||||
|
{
|
||||||
|
Category = FlagCategory.Employment.ToString(),
|
||||||
|
Severity = FlagSeverity.Info.ToString(),
|
||||||
|
Title = "Company Size Pattern",
|
||||||
|
Description = $"{pattern} ({string.Join(", ", sizeGroups.Select(kv => $"{kv.Key}: {kv.Value}"))})",
|
||||||
|
ScoreImpact = 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shows career trajectory direction (upward/lateral/step-down).
|
||||||
|
/// </summary>
|
||||||
|
private static void AddCareerTrajectoryFlag(List<EmploymentEntry> employment, List<FlagResult> flags)
|
||||||
|
{
|
||||||
|
var orderedRoles = employment
|
||||||
|
.Where(e => e.StartDate.HasValue && !string.IsNullOrWhiteSpace(e.JobTitle))
|
||||||
|
.OrderBy(e => e.StartDate)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (orderedRoles.Count < 3) return;
|
||||||
|
|
||||||
|
var seniorityLevels = orderedRoles.Select(e => GetSeniorityLevel(e.JobTitle)).ToList();
|
||||||
|
|
||||||
|
// Calculate average progression per transition
|
||||||
|
var transitions = new List<int>();
|
||||||
|
for (int i = 1; i < seniorityLevels.Count; i++)
|
||||||
|
{
|
||||||
|
transitions.Add(seniorityLevels[i] - seniorityLevels[i - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
var avgProgression = transitions.Average();
|
||||||
|
var firstLevel = seniorityLevels.First();
|
||||||
|
var lastLevel = seniorityLevels.Last();
|
||||||
|
var netChange = lastLevel - firstLevel;
|
||||||
|
|
||||||
|
string trajectory;
|
||||||
|
string description;
|
||||||
|
|
||||||
|
if (avgProgression > 0.3 && netChange > 0)
|
||||||
|
{
|
||||||
|
trajectory = "Upward";
|
||||||
|
var firstTitle = orderedRoles.First().JobTitle;
|
||||||
|
var lastTitle = orderedRoles.Last().JobTitle;
|
||||||
|
description = $"Career shows upward progression from {firstTitle} to {lastTitle} (net +{netChange} seniority levels)";
|
||||||
|
}
|
||||||
|
else if (avgProgression < -0.3 && netChange < 0)
|
||||||
|
{
|
||||||
|
trajectory = "Step-down";
|
||||||
|
description = $"Recent roles at lower seniority than earlier career (may indicate work-life balance choice, industry change, or consulting)";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
trajectory = "Lateral";
|
||||||
|
description = $"Career shows lateral movement - consistent seniority level across {orderedRoles.Count} roles";
|
||||||
|
}
|
||||||
|
|
||||||
|
flags.Add(new FlagResult
|
||||||
|
{
|
||||||
|
Category = FlagCategory.Employment.ToString(),
|
||||||
|
Severity = FlagSeverity.Info.ToString(),
|
||||||
|
Title = $"Career Trajectory: {trajectory}",
|
||||||
|
Description = description,
|
||||||
|
ScoreImpact = 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Highlights experience at publicly listed companies (PLCs).
|
||||||
|
/// </summary>
|
||||||
|
private static void AddPLCExperienceFlag(List<CompanyVerificationResult> verifications, List<FlagResult> flags)
|
||||||
|
{
|
||||||
|
var plcRoles = verifications
|
||||||
|
.Where(v => v.IsVerified)
|
||||||
|
.Where(v => !string.IsNullOrWhiteSpace(v.CompanyType) &&
|
||||||
|
(v.CompanyType.ToLowerInvariant().Contains("plc") ||
|
||||||
|
v.CompanyType.ToLowerInvariant().Contains("public-limited")))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (plcRoles.Count > 0)
|
||||||
|
{
|
||||||
|
var companies = string.Join(", ", plcRoles.Select(v => v.MatchedCompanyName).Distinct().Take(4));
|
||||||
|
var moreText = plcRoles.Select(v => v.MatchedCompanyName).Distinct().Count() > 4 ? " and others" : "";
|
||||||
|
|
||||||
|
flags.Add(new FlagResult
|
||||||
|
{
|
||||||
|
Category = FlagCategory.Employment.ToString(),
|
||||||
|
Severity = FlagSeverity.Info.ToString(),
|
||||||
|
Title = "Public Company Experience",
|
||||||
|
Description = $"{plcRoles.Count} role(s) at PLC/publicly listed companies: {companies}{moreText}",
|
||||||
|
ScoreImpact = 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Positively highlights when director claims are verified by Companies House.
|
||||||
|
/// </summary>
|
||||||
|
private static void AddVerifiedDirectorFlag(List<CompanyVerificationResult> verifications, List<FlagResult> flags)
|
||||||
|
{
|
||||||
|
// Look for director roles that DON'T have an UnverifiedDirectorClaim flag
|
||||||
|
// (meaning they were either verified or not checked)
|
||||||
|
var directorRoles = verifications
|
||||||
|
.Where(v => v.IsVerified && !string.IsNullOrWhiteSpace(v.ClaimedJobTitle))
|
||||||
|
.Where(v =>
|
||||||
|
{
|
||||||
|
var title = v.ClaimedJobTitle!.ToLowerInvariant();
|
||||||
|
return title.Contains("director") || title.Contains("company secretary") ||
|
||||||
|
title == "md" || title.Contains("managing director");
|
||||||
|
})
|
||||||
|
.Where(v => !v.Flags.Any(f => f.Type == "UnverifiedDirectorClaim"))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Only flag if we have verified directors (i.e., they were checked and confirmed)
|
||||||
|
// We can't distinguish "verified" from "not checked" without more context
|
||||||
|
// So we'll be conservative and only mention if there are director roles without red flags
|
||||||
|
if (directorRoles.Count > 0)
|
||||||
|
{
|
||||||
|
var companies = string.Join(", ", directorRoles.Select(v => v.MatchedCompanyName).Distinct().Take(3));
|
||||||
|
|
||||||
|
flags.Add(new FlagResult
|
||||||
|
{
|
||||||
|
Category = FlagCategory.Employment.ToString(),
|
||||||
|
Severity = FlagSeverity.Info.ToString(),
|
||||||
|
Title = "Director Experience",
|
||||||
|
Description = $"Director/senior officer role(s) at: {companies}",
|
||||||
|
ScoreImpact = 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
private static int GetSeniorityLevel(string? jobTitle)
|
private static int GetSeniorityLevel(string? jobTitle)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(jobTitle)) return 0;
|
if (string.IsNullOrWhiteSpace(jobTitle)) return 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user