Initial commit: TrueCV CV verification platform
Clean architecture solution with: - Domain: Entities (User, CVCheck, CVFlag, CompanyCache) and Enums - Application: Service interfaces, DTOs, and models - Infrastructure: EF Core, Identity, Hangfire, external API clients, services - Web: Blazor Server UI with pages and components Features: - CV upload and parsing (PDF/DOCX) using Claude API - Employment verification against Companies House API - Timeline analysis for gaps and overlaps - Veracity scoring algorithm - Background job processing with Hangfire - Azure Blob Storage for file storage Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
205
src/TrueCV.Infrastructure/Services/TimelineAnalyserService.cs
Normal file
205
src/TrueCV.Infrastructure/Services/TimelineAnalyserService.cs
Normal file
@@ -0,0 +1,205 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TrueCV.Application.Interfaces;
|
||||
using TrueCV.Application.Models;
|
||||
|
||||
namespace TrueCV.Infrastructure.Services;
|
||||
|
||||
public sealed class TimelineAnalyserService : ITimelineAnalyserService
|
||||
{
|
||||
private readonly ILogger<TimelineAnalyserService> _logger;
|
||||
|
||||
private const int MinimumGapMonths = 3;
|
||||
private const int AllowedOverlapMonths = 2;
|
||||
|
||||
public TimelineAnalyserService(ILogger<TimelineAnalyserService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public TimelineAnalysisResult Analyse(List<EmploymentEntry> employmentHistory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(employmentHistory);
|
||||
|
||||
if (employmentHistory.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No employment history to analyse");
|
||||
return new TimelineAnalysisResult
|
||||
{
|
||||
TotalGapMonths = 0,
|
||||
TotalOverlapMonths = 0,
|
||||
Gaps = [],
|
||||
Overlaps = []
|
||||
};
|
||||
}
|
||||
|
||||
// Filter entries with valid dates and sort by start date
|
||||
var sortedEmployment = employmentHistory
|
||||
.Where(e => e.StartDate.HasValue)
|
||||
.OrderBy(e => e.StartDate!.Value)
|
||||
.ToList();
|
||||
|
||||
if (sortedEmployment.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No employment entries with valid dates to analyse");
|
||||
return new TimelineAnalysisResult
|
||||
{
|
||||
TotalGapMonths = 0,
|
||||
TotalOverlapMonths = 0,
|
||||
Gaps = [],
|
||||
Overlaps = []
|
||||
};
|
||||
}
|
||||
|
||||
var gaps = DetectGaps(sortedEmployment);
|
||||
var overlaps = DetectOverlaps(sortedEmployment);
|
||||
|
||||
var totalGapMonths = gaps.Sum(g => g.Months);
|
||||
var totalOverlapMonths = overlaps.Sum(o => o.Months);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Timeline analysis complete: {GapCount} gaps ({TotalGapMonths} months), {OverlapCount} overlaps ({TotalOverlapMonths} months)",
|
||||
gaps.Count, totalGapMonths, overlaps.Count, totalOverlapMonths);
|
||||
|
||||
return new TimelineAnalysisResult
|
||||
{
|
||||
TotalGapMonths = totalGapMonths,
|
||||
TotalOverlapMonths = totalOverlapMonths,
|
||||
Gaps = gaps,
|
||||
Overlaps = overlaps
|
||||
};
|
||||
}
|
||||
|
||||
private List<TimelineGap> DetectGaps(List<EmploymentEntry> sortedEmployment)
|
||||
{
|
||||
var gaps = new List<TimelineGap>();
|
||||
|
||||
for (var i = 0; i < sortedEmployment.Count - 1; i++)
|
||||
{
|
||||
var current = sortedEmployment[i];
|
||||
var next = sortedEmployment[i + 1];
|
||||
|
||||
// Get the effective end date for the current position
|
||||
var currentEndDate = GetEffectiveEndDate(current);
|
||||
var nextStartDate = next.StartDate!.Value;
|
||||
|
||||
// Skip if there's no gap or overlap
|
||||
if (currentEndDate >= nextStartDate)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var gapMonths = CalculateMonthsDifference(currentEndDate, nextStartDate);
|
||||
|
||||
// Only report gaps of 3+ months
|
||||
if (gapMonths >= MinimumGapMonths)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Detected {Months} month gap between {EndDate} and {StartDate}",
|
||||
gapMonths, currentEndDate, nextStartDate);
|
||||
|
||||
gaps.Add(new TimelineGap
|
||||
{
|
||||
StartDate = currentEndDate,
|
||||
EndDate = nextStartDate,
|
||||
Months = gapMonths
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return gaps;
|
||||
}
|
||||
|
||||
private List<TimelineOverlap> DetectOverlaps(List<EmploymentEntry> sortedEmployment)
|
||||
{
|
||||
var overlaps = new List<TimelineOverlap>();
|
||||
|
||||
for (var i = 0; i < sortedEmployment.Count; i++)
|
||||
{
|
||||
for (var j = i + 1; j < sortedEmployment.Count; j++)
|
||||
{
|
||||
var earlier = sortedEmployment[i];
|
||||
var later = sortedEmployment[j];
|
||||
|
||||
var overlap = CalculateOverlap(earlier, later);
|
||||
|
||||
if (overlap is not null && overlap.Value.Months > AllowedOverlapMonths)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Detected {Months} month overlap between {Company1} and {Company2}",
|
||||
overlap.Value.Months, earlier.CompanyName, later.CompanyName);
|
||||
|
||||
overlaps.Add(new TimelineOverlap
|
||||
{
|
||||
Company1 = earlier.CompanyName,
|
||||
Company2 = later.CompanyName,
|
||||
OverlapStart = overlap.Value.Start,
|
||||
OverlapEnd = overlap.Value.End,
|
||||
Months = overlap.Value.Months
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return overlaps;
|
||||
}
|
||||
|
||||
private static (DateOnly Start, DateOnly End, int Months)? CalculateOverlap(
|
||||
EmploymentEntry earlier,
|
||||
EmploymentEntry later)
|
||||
{
|
||||
if (!earlier.StartDate.HasValue || !later.StartDate.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var earlierEnd = GetEffectiveEndDate(earlier);
|
||||
var laterStart = later.StartDate.Value;
|
||||
|
||||
// No overlap if earlier job ended before later job started
|
||||
if (earlierEnd <= laterStart)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var laterEnd = GetEffectiveEndDate(later);
|
||||
|
||||
// The overlap period
|
||||
var overlapStart = laterStart;
|
||||
var overlapEnd = earlierEnd < laterEnd ? earlierEnd : laterEnd;
|
||||
|
||||
if (overlapStart >= overlapEnd)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var months = CalculateMonthsDifference(overlapStart, overlapEnd);
|
||||
|
||||
return (overlapStart, overlapEnd, months);
|
||||
}
|
||||
|
||||
private static DateOnly GetEffectiveEndDate(EmploymentEntry entry)
|
||||
{
|
||||
if (entry.EndDate.HasValue)
|
||||
{
|
||||
return entry.EndDate.Value;
|
||||
}
|
||||
|
||||
// If marked as current or no end date, use today
|
||||
return DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
}
|
||||
|
||||
private static int CalculateMonthsDifference(DateOnly startDate, DateOnly endDate)
|
||||
{
|
||||
var yearDiff = endDate.Year - startDate.Year;
|
||||
var monthDiff = endDate.Month - startDate.Month;
|
||||
var totalMonths = (yearDiff * 12) + monthDiff;
|
||||
|
||||
// Add a month if we've passed the day in the month
|
||||
if (endDate.Day >= startDate.Day)
|
||||
{
|
||||
totalMonths++;
|
||||
}
|
||||
|
||||
return Math.Max(0, totalMonths);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user