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;