diff --git a/src/TrueCV.Infrastructure/Jobs/ProcessCVCheckJob.cs b/src/TrueCV.Infrastructure/Jobs/ProcessCVCheckJob.cs index 45aecb4..22e20f2 100644 --- a/src/TrueCV.Infrastructure/Jobs/ProcessCVCheckJob.cs +++ b/src/TrueCV.Infrastructure/Jobs/ProcessCVCheckJob.cs @@ -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) + + /// + /// Adds a flag showing total career span from earliest to latest employment. + /// + private static void AddCareerSpanFlag(List employment, List 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 + }); + } + + /// + /// Adds a flag showing current employment status. + /// + private static void AddCurrentEmploymentStatusFlag(List employment, List 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 + }); + } + } + } + } + + /// + /// Highlights roles with particularly long tenure (5+ years). + /// + private static void AddLongTenureFlags(List employment, List 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 + }); + } + } + } + + /// + /// Indicates management vs individual contributor experience. + /// + private static void AddManagementExperienceFlag(List employment, List 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 + }); + } + } + + /// + /// Shows pattern of company sizes (startup/SME/corporate). + /// + private static void AddCompanySizePatternFlag(List verifications, List 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 + }); + } + + /// + /// Shows career trajectory direction (upward/lateral/step-down). + /// + private static void AddCareerTrajectoryFlag(List employment, List 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(); + 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 + }); + } + + /// + /// Highlights experience at publicly listed companies (PLCs). + /// + private static void AddPLCExperienceFlag(List verifications, List 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 + }); + } + } + + /// + /// Positively highlights when director claims are verified by Companies House. + /// + private static void AddVerifiedDirectorFlag(List verifications, List 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;