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)
|
||||
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)
|
||||
foreach (var edu in educationResults.Where(e => e.IsDiplomaMill))
|
||||
{
|
||||
@@ -757,6 +767,378 @@ public sealed class ProcessCVCheckJob
|
||||
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)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(jobTitle)) return 0;
|
||||
|
||||
Reference in New Issue
Block a user