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:
2026-01-20 23:02:58 +01:00
parent f711c9725e
commit 1aeaba7fbb

View File

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