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:
@@ -341,6 +341,9 @@ public sealed class ProcessCVCheckJob
|
|||||||
// Check for early career senior roles (relative to education end date)
|
// Check for early career senior roles (relative to education end date)
|
||||||
CheckEarlyCareerSeniorRoles(cvData.Employment, cvData.Education, flags, ref score);
|
CheckEarlyCareerSeniorRoles(cvData.Employment, cvData.Education, flags, ref score);
|
||||||
|
|
||||||
|
// Check for frequent job changes (informational only)
|
||||||
|
CheckFrequentJobChanges(cvData.Employment, 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))
|
||||||
{
|
{
|
||||||
@@ -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)
|
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