diff --git a/src/TrueCV.Infrastructure/Jobs/ProcessCVCheckJob.cs b/src/TrueCV.Infrastructure/Jobs/ProcessCVCheckJob.cs index cfc6cf5..45aecb4 100644 --- a/src/TrueCV.Infrastructure/Jobs/ProcessCVCheckJob.cs +++ b/src/TrueCV.Infrastructure/Jobs/ProcessCVCheckJob.cs @@ -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 employment, + List 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 + }); + } + } + + /// + /// Normalizes company name for grouping purposes. + /// Groups companies like "BMW UK", "BMW Group", "BMW (UK) Ltd" together. + /// + 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;