268 lines
9.8 KiB
C#
268 lines
9.8 KiB
C#
|
|
using TrueCV.Application.Data;
|
||
|
|
using TrueCV.Application.Interfaces;
|
||
|
|
using TrueCV.Application.Models;
|
||
|
|
|
||
|
|
namespace TrueCV.Infrastructure.Services;
|
||
|
|
|
||
|
|
public 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
|
||
|
|
return new EducationVerificationResult
|
||
|
|
{
|
||
|
|
ClaimedInstitution = institution,
|
||
|
|
Status = "Unknown",
|
||
|
|
IsVerified = false,
|
||
|
|
IsDiplomaMill = false,
|
||
|
|
IsSuspicious = false,
|
||
|
|
VerificationNotes = "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}";
|
||
|
|
}
|
||
|
|
}
|