refactor: Rename TrueCV to RealCV throughout codebase
- Renamed all directories (TrueCV.* -> RealCV.*) - Renamed all project files (.csproj) - Renamed solution file (TrueCV.sln -> RealCV.sln) - Updated all namespaces in C# and Razor files - Updated project references - Updated CSS variable names 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
271
src/RealCV.Infrastructure/Services/EducationVerifierService.cs
Normal file
271
src/RealCV.Infrastructure/Services/EducationVerifierService.cs
Normal file
@@ -0,0 +1,271 @@
|
||||
using RealCV.Application.Data;
|
||||
using RealCV.Application.Interfaces;
|
||||
using RealCV.Application.Models;
|
||||
|
||||
namespace RealCV.Infrastructure.Services;
|
||||
|
||||
public sealed class EducationVerifierService : IEducationVerifierService
|
||||
{
|
||||
private const int MinimumDegreeYears = 1;
|
||||
private const int MaximumDegreeYears = 8;
|
||||
private const int MinimumGraduationAge = 18;
|
||||
|
||||
public EducationVerificationResult Verify(EducationEntry education)
|
||||
{
|
||||
var institution = education.Institution;
|
||||
|
||||
// Check for diploma mill first (highest priority flag)
|
||||
if (DiplomaMills.IsDiplomaMill(institution))
|
||||
{
|
||||
return new EducationVerificationResult
|
||||
{
|
||||
ClaimedInstitution = institution,
|
||||
Status = "DiplomaMill",
|
||||
IsVerified = false,
|
||||
IsDiplomaMill = true,
|
||||
IsSuspicious = true,
|
||||
VerificationNotes = "Institution is on the diploma mill blacklist",
|
||||
ClaimedStartDate = education.StartDate,
|
||||
ClaimedEndDate = education.EndDate,
|
||||
DatesArePlausible = true,
|
||||
ClaimedQualification = education.Qualification,
|
||||
ClaimedSubject = education.Subject
|
||||
};
|
||||
}
|
||||
|
||||
// Check for suspicious patterns
|
||||
if (DiplomaMills.HasSuspiciousPattern(institution))
|
||||
{
|
||||
return new EducationVerificationResult
|
||||
{
|
||||
ClaimedInstitution = institution,
|
||||
Status = "Suspicious",
|
||||
IsVerified = false,
|
||||
IsDiplomaMill = false,
|
||||
IsSuspicious = true,
|
||||
VerificationNotes = "Institution name contains suspicious patterns common in diploma mills",
|
||||
ClaimedStartDate = education.StartDate,
|
||||
ClaimedEndDate = education.EndDate,
|
||||
DatesArePlausible = true,
|
||||
ClaimedQualification = education.Qualification,
|
||||
ClaimedSubject = education.Subject
|
||||
};
|
||||
}
|
||||
|
||||
// Check if it's a recognised UK institution
|
||||
var officialName = UKInstitutions.GetOfficialName(institution);
|
||||
if (officialName != null)
|
||||
{
|
||||
var (datesPlausible, dateNotes) = CheckDatePlausibility(education.StartDate, education.EndDate);
|
||||
|
||||
return new EducationVerificationResult
|
||||
{
|
||||
ClaimedInstitution = institution,
|
||||
MatchedInstitution = officialName,
|
||||
Status = "Recognised",
|
||||
IsVerified = true,
|
||||
IsDiplomaMill = false,
|
||||
IsSuspicious = false,
|
||||
VerificationNotes = institution.Equals(officialName, StringComparison.OrdinalIgnoreCase)
|
||||
? "Verified UK higher education institution"
|
||||
: $"Matched to official name: {officialName}",
|
||||
ClaimedStartDate = education.StartDate,
|
||||
ClaimedEndDate = education.EndDate,
|
||||
DatesArePlausible = datesPlausible,
|
||||
DatePlausibilityNotes = dateNotes,
|
||||
ClaimedQualification = education.Qualification,
|
||||
ClaimedSubject = education.Subject
|
||||
};
|
||||
}
|
||||
|
||||
// Not in our database - could be international or unrecognised
|
||||
var isUnknownInstitution = string.IsNullOrWhiteSpace(institution) ||
|
||||
institution.Equals("Unknown Institution", StringComparison.OrdinalIgnoreCase) ||
|
||||
institution.Equals("Unknown", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return new EducationVerificationResult
|
||||
{
|
||||
ClaimedInstitution = institution,
|
||||
Status = "Unknown",
|
||||
IsVerified = false,
|
||||
IsDiplomaMill = false,
|
||||
IsSuspicious = false,
|
||||
VerificationNotes = isUnknownInstitution ? null : "Institution not found in UK recognised institutions database. May be an international institution.",
|
||||
ClaimedStartDate = education.StartDate,
|
||||
ClaimedEndDate = education.EndDate,
|
||||
DatesArePlausible = true,
|
||||
ClaimedQualification = education.Qualification,
|
||||
ClaimedSubject = education.Subject
|
||||
};
|
||||
}
|
||||
|
||||
public List<EducationVerificationResult> VerifyAll(
|
||||
List<EducationEntry> education,
|
||||
List<EmploymentEntry>? employment = null)
|
||||
{
|
||||
var results = new List<EducationVerificationResult>();
|
||||
|
||||
foreach (var edu in education)
|
||||
{
|
||||
var result = Verify(edu);
|
||||
|
||||
// If we have employment data, check for timeline issues
|
||||
if (employment?.Count > 0 && result.ClaimedEndDate.HasValue)
|
||||
{
|
||||
var (timelinePlausible, timelineNotes) = CheckEducationEmploymentTimeline(
|
||||
result.ClaimedEndDate.Value,
|
||||
employment);
|
||||
|
||||
if (!timelinePlausible)
|
||||
{
|
||||
result = result with
|
||||
{
|
||||
DatesArePlausible = false,
|
||||
DatePlausibilityNotes = CombineNotes(result.DatePlausibilityNotes, timelineNotes)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
// Check for overlapping education periods
|
||||
CheckOverlappingEducation(results);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static (bool isPlausible, string? notes) CheckDatePlausibility(DateOnly? startDate, DateOnly? endDate)
|
||||
{
|
||||
if (!startDate.HasValue || !endDate.HasValue)
|
||||
{
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
var start = startDate.Value;
|
||||
var end = endDate.Value;
|
||||
|
||||
// End date should be after start date
|
||||
if (end <= start)
|
||||
{
|
||||
return (false, "End date is before or equal to start date");
|
||||
}
|
||||
|
||||
// Check course duration is reasonable
|
||||
var years = (end.ToDateTime(TimeOnly.MinValue) - start.ToDateTime(TimeOnly.MinValue)).TotalDays / 365.25;
|
||||
|
||||
if (years < MinimumDegreeYears)
|
||||
{
|
||||
return (false, $"Course duration ({years:F1} years) is unusually short for a degree");
|
||||
}
|
||||
|
||||
if (years > MaximumDegreeYears)
|
||||
{
|
||||
return (false, $"Course duration ({years:F1} years) is unusually long");
|
||||
}
|
||||
|
||||
// Check if graduation date is in the future
|
||||
if (end > DateOnly.FromDateTime(DateTime.UtcNow))
|
||||
{
|
||||
return (true, "Graduation date is in the future - possibly currently studying");
|
||||
}
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
private static (bool isPlausible, string? notes) CheckEducationEmploymentTimeline(
|
||||
DateOnly graduationDate,
|
||||
List<EmploymentEntry> employment)
|
||||
{
|
||||
// Find the earliest employment start date
|
||||
var earliestEmployment = employment
|
||||
.Where(e => e.StartDate.HasValue)
|
||||
.OrderBy(e => e.StartDate)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (earliestEmployment?.StartDate == null)
|
||||
{
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
var employmentStart = earliestEmployment.StartDate.Value;
|
||||
|
||||
// If someone claims to have started full-time work significantly before graduating,
|
||||
// that's suspicious (unless it's clearly an internship/part-time role)
|
||||
var monthsBeforeGraduation = (graduationDate.ToDateTime(TimeOnly.MinValue) -
|
||||
employmentStart.ToDateTime(TimeOnly.MinValue)).TotalDays / 30;
|
||||
|
||||
if (monthsBeforeGraduation > 24) // More than 2 years before graduation
|
||||
{
|
||||
var isLikelyInternship = earliestEmployment.JobTitle.Contains("intern", StringComparison.OrdinalIgnoreCase) ||
|
||||
earliestEmployment.JobTitle.Contains("placement", StringComparison.OrdinalIgnoreCase) ||
|
||||
earliestEmployment.JobTitle.Contains("trainee", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!isLikelyInternship)
|
||||
{
|
||||
return (false, $"Employment at {earliestEmployment.CompanyName} started {monthsBeforeGraduation:F0} months before claimed graduation");
|
||||
}
|
||||
}
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
private static void CheckOverlappingEducation(List<EducationVerificationResult> results)
|
||||
{
|
||||
var datedResults = results
|
||||
.Where(r => r.ClaimedStartDate.HasValue && r.ClaimedEndDate.HasValue)
|
||||
.ToList();
|
||||
|
||||
for (var i = 0; i < datedResults.Count; i++)
|
||||
{
|
||||
for (var j = i + 1; j < datedResults.Count; j++)
|
||||
{
|
||||
var edu1 = datedResults[i];
|
||||
var edu2 = datedResults[j];
|
||||
|
||||
if (PeriodsOverlap(
|
||||
edu1.ClaimedStartDate!.Value, edu1.ClaimedEndDate!.Value,
|
||||
edu2.ClaimedStartDate!.Value, edu2.ClaimedEndDate!.Value))
|
||||
{
|
||||
// Find the actual index in the original results list
|
||||
var idx1 = results.IndexOf(edu1);
|
||||
var idx2 = results.IndexOf(edu2);
|
||||
|
||||
if (idx1 >= 0)
|
||||
{
|
||||
results[idx1] = edu1 with
|
||||
{
|
||||
DatePlausibilityNotes = CombineNotes(
|
||||
edu1.DatePlausibilityNotes,
|
||||
$"Overlaps with education at {edu2.ClaimedInstitution}")
|
||||
};
|
||||
}
|
||||
|
||||
if (idx2 >= 0)
|
||||
{
|
||||
results[idx2] = edu2 with
|
||||
{
|
||||
DatePlausibilityNotes = CombineNotes(
|
||||
edu2.DatePlausibilityNotes,
|
||||
$"Overlaps with education at {edu1.ClaimedInstitution}")
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool PeriodsOverlap(DateOnly start1, DateOnly end1, DateOnly start2, DateOnly end2)
|
||||
{
|
||||
return start1 < end2 && start2 < end1;
|
||||
}
|
||||
|
||||
private static string? CombineNotes(string? existing, string? additional)
|
||||
{
|
||||
if (string.IsNullOrEmpty(additional))
|
||||
return existing;
|
||||
if (string.IsNullOrEmpty(existing))
|
||||
return additional;
|
||||
return $"{existing}; {additional}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user