Add frequent job changes detection (informational flag)

- Add CheckFrequentJobChanges() to detect short tenure patterns
- Flag when 3+ employers have <18 month tenure (no penalty, info only)
- Group multiple roles at same company/group together
- Calculate and display average tenure across all employers
- Add NormalizeCompanyForGrouping() to handle company name variations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-20 23:00:07 +01:00
parent a6b24d2c64
commit f711c9725e

View File

@@ -341,6 +341,9 @@ public sealed class ProcessCVCheckJob
// Check for early career senior roles (relative to education end date)
CheckEarlyCareerSeniorRoles(cvData.Employment, cvData.Education, flags, ref score);
// Check for frequent job changes (informational only)
CheckFrequentJobChanges(cvData.Employment, flags);
// Penalty for diploma mills (critical)
foreach (var edu in educationResults.Where(e => e.IsDiplomaMill))
{
@@ -633,6 +636,127 @@ public sealed class ProcessCVCheckJob
}
}
private const int ShortTenureMonths = 18;
private const int MinShortTenuresForFlag = 3;
private static void CheckFrequentJobChanges(
List<EmploymentEntry> employment,
List<FlagResult> flags)
{
// Group employment by normalized company name (to combine roles at same employer)
var employerTenures = employment
.Where(e => !string.IsNullOrWhiteSpace(e.CompanyName) && e.StartDate.HasValue)
.Where(e => !IsFreelance(e.CompanyName)) // Exclude freelance
.GroupBy(e => NormalizeCompanyForGrouping(e.CompanyName))
.Select(g =>
{
// Calculate total tenure at this employer (sum of all roles)
var totalMonths = 0;
foreach (var role in g)
{
if (role.StartDate.HasValue)
{
var endDate = role.EndDate ?? DateOnly.FromDateTime(DateTime.Today);
var months = ((endDate.Year - role.StartDate.Value.Year) * 12) +
(endDate.Month - role.StartDate.Value.Month);
totalMonths += Math.Max(0, months);
}
}
return new
{
CompanyGroup = g.Key,
DisplayName = g.First().CompanyName,
TotalMonths = totalMonths,
RoleCount = g.Count()
};
})
.Where(t => t.TotalMonths > 0) // Exclude zero-tenure entries
.ToList();
if (employerTenures.Count == 0) return;
// Find short tenures (less than 18 months) at different companies
var shortTenures = employerTenures
.Where(t => t.TotalMonths < ShortTenureMonths)
.ToList();
// Calculate average tenure across unique employers
var avgTenureMonths = employerTenures.Average(t => t.TotalMonths);
var avgTenureYears = avgTenureMonths / 12.0;
// If 3+ different companies with short tenure, flag it (informational only)
if (shortTenures.Count >= MinShortTenuresForFlag)
{
var shortTenureCompanies = string.Join(", ", shortTenures.Take(5).Select(t => $"{t.DisplayName} ({t.TotalMonths}mo)"));
var moreCount = shortTenures.Count > 5 ? $" and {shortTenures.Count - 5} more" : "";
flags.Add(new FlagResult
{
Category = FlagCategory.Timeline.ToString(),
Severity = FlagSeverity.Info.ToString(),
Title = "Frequent Job Changes",
Description = $"Candidate has {shortTenures.Count} employers with tenure under {ShortTenureMonths} months: {shortTenureCompanies}{moreCount}. Average tenure: {avgTenureYears:F1} years across {employerTenures.Count} employers.",
ScoreImpact = 0 // Informational only, no penalty
});
}
// Even without frequent changes, note average tenure if it's low
else if (avgTenureMonths < 24 && employerTenures.Count >= 3)
{
flags.Add(new FlagResult
{
Category = FlagCategory.Timeline.ToString(),
Severity = FlagSeverity.Info.ToString(),
Title = "Average Tenure",
Description = $"Average tenure: {avgTenureYears:F1} years across {employerTenures.Count} employers.",
ScoreImpact = 0 // Informational only
});
}
}
/// <summary>
/// Normalizes company name for grouping purposes.
/// Groups companies like "BMW UK", "BMW Group", "BMW (UK) Ltd" together.
/// </summary>
private static string NormalizeCompanyForGrouping(string companyName)
{
if (string.IsNullOrWhiteSpace(companyName)) return "";
var name = companyName.ToLowerInvariant().Trim();
// Remove common suffixes
var suffixes = new[] { " limited", " ltd", " plc", " llp", " inc", " corporation", " corp",
" uk", " (uk)", " u.k.", " group", " holdings", " services" };
foreach (var suffix in suffixes)
{
if (name.EndsWith(suffix))
{
name = name[..^suffix.Length].Trim();
}
}
// Remove parenthetical content
name = System.Text.RegularExpressions.Regex.Replace(name, @"\([^)]*\)", "").Trim();
// Take first significant word(s) as the company identifier
// This helps group "Unilever Bestfood" with "Unilever UK"
var words = name.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (words.Length >= 1)
{
// Use first word if it's substantial (4+ chars), or first two words
if (words[0].Length >= 4)
{
return words[0];
}
else if (words.Length >= 2)
{
return words[0] + " " + words[1];
}
}
return name;
}
private static int GetSeniorityLevel(string? jobTitle)
{
if (string.IsNullOrWhiteSpace(jobTitle)) return 0;