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:
2026-01-18 19:20:50 +01:00
commit 6d514e01b2
70 changed files with 5996 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
namespace TrueCV.Infrastructure.Configuration;
public sealed class AnthropicSettings
{
public const string SectionName = "Anthropic";
public required string ApiKey { get; init; }
}

View File

@@ -0,0 +1,9 @@
namespace TrueCV.Infrastructure.Configuration;
public sealed class AzureBlobSettings
{
public const string SectionName = "AzureBlob";
public required string ConnectionString { get; init; }
public required string ContainerName { get; init; }
}

View File

@@ -0,0 +1,9 @@
namespace TrueCV.Infrastructure.Configuration;
public sealed class CompaniesHouseSettings
{
public const string SectionName = "CompaniesHouse";
public required string BaseUrl { get; init; }
public required string ApiKey { get; init; }
}

View File

@@ -0,0 +1,114 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using TrueCV.Domain.Entities;
using TrueCV.Domain.Enums;
using TrueCV.Infrastructure.Identity;
namespace TrueCV.Infrastructure.Data;
public class ApplicationDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid>, Guid>
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
public DbSet<CVCheck> CVChecks => Set<CVCheck>();
public DbSet<CVFlag> CVFlags => Set<CVFlag>();
public DbSet<CompanyCache> CompanyCache => Set<CompanyCache>();
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
ConfigureApplicationUser(builder);
ConfigureCVCheck(builder);
ConfigureCVFlag(builder);
ConfigureCompanyCache(builder);
}
private static void ConfigureApplicationUser(ModelBuilder builder)
{
builder.Entity<ApplicationUser>(entity =>
{
entity.Property(u => u.Plan)
.HasConversion<string>()
.HasMaxLength(32);
entity.Property(u => u.StripeCustomerId)
.HasMaxLength(256);
entity.HasMany(u => u.CVChecks)
.WithOne()
.HasForeignKey(c => c.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
}
private static void ConfigureCVCheck(ModelBuilder builder)
{
builder.Entity<CVCheck>(entity =>
{
entity.Property(c => c.Status)
.HasConversion<string>()
.HasMaxLength(32);
entity.HasIndex(c => c.UserId)
.HasDatabaseName("IX_CVChecks_UserId");
entity.HasIndex(c => c.Status)
.HasDatabaseName("IX_CVChecks_Status");
entity.HasMany(c => c.Flags)
.WithOne(f => f.CVCheck)
.HasForeignKey(f => f.CVCheckId)
.OnDelete(DeleteBehavior.Cascade);
// Ignore the User navigation property since we're using ApplicationUser
entity.Ignore(c => c.User);
});
}
private static void ConfigureCVFlag(ModelBuilder builder)
{
builder.Entity<CVFlag>(entity =>
{
entity.Property(f => f.Category)
.HasConversion<string>()
.HasMaxLength(32);
entity.Property(f => f.Severity)
.HasConversion<string>()
.HasMaxLength(32);
entity.HasIndex(f => f.CVCheckId)
.HasDatabaseName("IX_CVFlags_CVCheckId");
});
}
private static void ConfigureCompanyCache(ModelBuilder builder)
{
builder.Entity<CompanyCache>(entity =>
{
entity.HasKey(c => c.CompanyNumber);
entity.Property(c => c.CompanyNumber)
.HasMaxLength(32);
});
}
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
var newCVChecks = ChangeTracker.Entries<CVCheck>()
.Where(e => e.State == EntityState.Added)
.Select(e => e.Entity);
foreach (var cvCheck in newCVChecks)
{
cvCheck.CreatedAt = DateTime.UtcNow;
}
return await base.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,113 @@
using Azure.Storage.Blobs;
using Hangfire;
using Hangfire.SqlServer;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Polly;
using Polly.Extensions.Http;
using TrueCV.Application.Interfaces;
using TrueCV.Infrastructure.Configuration;
using TrueCV.Infrastructure.Data;
using TrueCV.Infrastructure.ExternalApis;
using TrueCV.Infrastructure.Jobs;
using TrueCV.Infrastructure.Services;
namespace TrueCV.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
// Configure DbContext with SQL Server
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(
configuration.GetConnectionString("DefaultConnection"),
sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 3,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorNumbersToAdd: null);
}));
// Configure Hangfire with SQL Server storage
services.AddHangfire(config => config
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UseSqlServerStorage(
configuration.GetConnectionString("HangfireConnection"),
new SqlServerStorageOptions
{
CommandBatchMaxTimeout = TimeSpan.FromMinutes(5),
SlidingInvisibilityTimeout = TimeSpan.FromMinutes(5),
QueuePollInterval = TimeSpan.Zero,
UseRecommendedIsolationLevel = true,
DisableGlobalLocks = true
}));
services.AddHangfireServer();
// Configure options
services.Configure<CompaniesHouseSettings>(
configuration.GetSection(CompaniesHouseSettings.SectionName));
services.Configure<AnthropicSettings>(
configuration.GetSection(AnthropicSettings.SectionName));
services.Configure<AzureBlobSettings>(
configuration.GetSection(AzureBlobSettings.SectionName));
// Configure HttpClient for CompaniesHouseClient with retry policy
services.AddHttpClient<CompaniesHouseClient>((serviceProvider, client) =>
{
var settings = configuration
.GetSection(CompaniesHouseSettings.SectionName)
.Get<CompaniesHouseSettings>();
if (settings is not null)
{
client.BaseAddress = new Uri(settings.BaseUrl);
}
})
.AddPolicyHandler(GetRetryPolicy());
// Configure BlobServiceClient
var azureBlobConnectionString = configuration
.GetSection(AzureBlobSettings.SectionName)
.GetValue<string>("ConnectionString");
if (!string.IsNullOrWhiteSpace(azureBlobConnectionString))
{
services.AddSingleton(_ => new BlobServiceClient(azureBlobConnectionString));
}
// Register services
services.AddScoped<ICVParserService, CVParserService>();
services.AddScoped<ICompanyVerifierService, CompanyVerifierService>();
services.AddScoped<ITimelineAnalyserService, TimelineAnalyserService>();
services.AddScoped<IFileStorageService, FileStorageService>();
services.AddScoped<ICVCheckService, CVCheckService>();
// Register Hangfire jobs
services.AddTransient<ProcessCVCheckJob>();
return services;
}
private static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
{
return HttpPolicyExtensions
.HandleTransientHttpError()
.OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: retryAttempt =>
TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
onRetry: (outcome, timespan, retryAttempt, context) =>
{
// Logging could be added here via ILogger if injected
});
}
}

View File

@@ -0,0 +1,169 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TrueCV.Application.DTOs;
using TrueCV.Infrastructure.Configuration;
namespace TrueCV.Infrastructure.ExternalApis;
public sealed class CompaniesHouseClient
{
private readonly HttpClient _httpClient;
private readonly ILogger<CompaniesHouseClient> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = true
};
public CompaniesHouseClient(
HttpClient httpClient,
IOptions<CompaniesHouseSettings> settings,
ILogger<CompaniesHouseClient> logger)
{
_httpClient = httpClient;
_logger = logger;
var apiKey = settings.Value.ApiKey;
var authValue = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{apiKey}:"));
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", authValue);
_httpClient.BaseAddress = new Uri(settings.Value.BaseUrl);
}
public async Task<CompaniesHouseSearchResponse?> SearchCompaniesAsync(
string query,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(query);
var encodedQuery = Uri.EscapeDataString(query);
var requestUrl = $"/search/companies?q={encodedQuery}";
_logger.LogDebug("Searching Companies House for: {Query}", query);
try
{
var response = await _httpClient.GetAsync(requestUrl, cancellationToken);
if (response.StatusCode == HttpStatusCode.TooManyRequests)
{
_logger.LogWarning("Rate limit exceeded for Companies House API");
throw new CompaniesHouseRateLimitException("Rate limit exceeded. Please try again later.");
}
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<CompaniesHouseSearchResponse>(
JsonOptions,
cancellationToken);
_logger.LogDebug("Found {Count} companies matching query: {Query}",
result?.Items?.Count ?? 0, query);
return result;
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests)
{
_logger.LogWarning("Rate limit exceeded for Companies House API");
throw new CompaniesHouseRateLimitException("Rate limit exceeded. Please try again later.", ex);
}
}
public async Task<CompaniesHouseCompany?> GetCompanyAsync(
string companyNumber,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(companyNumber);
var requestUrl = $"/company/{companyNumber}";
_logger.LogDebug("Fetching company details for: {CompanyNumber}", companyNumber);
try
{
var response = await _httpClient.GetAsync(requestUrl, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
{
_logger.LogDebug("Company not found: {CompanyNumber}", companyNumber);
return null;
}
if (response.StatusCode == HttpStatusCode.TooManyRequests)
{
_logger.LogWarning("Rate limit exceeded for Companies House API");
throw new CompaniesHouseRateLimitException("Rate limit exceeded. Please try again later.");
}
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<CompaniesHouseCompany>(
JsonOptions,
cancellationToken);
_logger.LogDebug("Retrieved company: {CompanyName} ({CompanyNumber})",
result?.CompanyName, companyNumber);
return result;
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests)
{
_logger.LogWarning("Rate limit exceeded for Companies House API");
throw new CompaniesHouseRateLimitException("Rate limit exceeded. Please try again later.", ex);
}
}
}
// DTOs for Companies House API responses
public sealed record CompaniesHouseSearchResponse
{
public int TotalResults { get; init; }
public int ItemsPerPage { get; init; }
public int StartIndex { get; init; }
public List<CompaniesHouseSearchItem> Items { get; init; } = [];
}
public sealed record CompaniesHouseSearchItem
{
public required string CompanyNumber { get; init; }
public required string Title { get; init; }
public string? CompanyStatus { get; init; }
public string? CompanyType { get; init; }
public string? DateOfCreation { get; init; }
public string? DateOfCessation { get; init; }
public CompaniesHouseAddress? Address { get; init; }
public string? AddressSnippet { get; init; }
}
public sealed record CompaniesHouseCompany
{
public required string CompanyNumber { get; init; }
public required string CompanyName { get; init; }
public string? CompanyStatus { get; init; }
public string? Type { get; init; }
public string? DateOfCreation { get; init; }
public string? DateOfCessation { get; init; }
public CompaniesHouseAddress? RegisteredOfficeAddress { get; init; }
}
public sealed record CompaniesHouseAddress
{
public string? Premises { get; init; }
public string? AddressLine1 { get; init; }
public string? AddressLine2 { get; init; }
public string? Locality { get; init; }
public string? Region { get; init; }
public string? PostalCode { get; init; }
public string? Country { get; init; }
}
public class CompaniesHouseRateLimitException : Exception
{
public CompaniesHouseRateLimitException(string message) : base(message) { }
public CompaniesHouseRateLimitException(string message, Exception innerException) : base(message, innerException) { }
}

View File

@@ -0,0 +1,16 @@
using Microsoft.AspNetCore.Identity;
using TrueCV.Domain.Entities;
using TrueCV.Domain.Enums;
namespace TrueCV.Infrastructure.Identity;
public class ApplicationUser : IdentityUser<Guid>
{
public UserPlan Plan { get; set; }
public string? StripeCustomerId { get; set; }
public int ChecksUsedThisMonth { get; set; }
public ICollection<CVCheck> CVChecks { get; set; } = new List<CVCheck>();
}

View File

@@ -0,0 +1,241 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using TrueCV.Application.Interfaces;
using TrueCV.Application.Models;
using TrueCV.Domain.Entities;
using TrueCV.Domain.Enums;
using TrueCV.Infrastructure.Data;
namespace TrueCV.Infrastructure.Jobs;
public sealed class ProcessCVCheckJob
{
private readonly ApplicationDbContext _dbContext;
private readonly IFileStorageService _fileStorageService;
private readonly ICVParserService _cvParserService;
private readonly ICompanyVerifierService _companyVerifierService;
private readonly ITimelineAnalyserService _timelineAnalyserService;
private readonly ILogger<ProcessCVCheckJob> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
private const int BaseScore = 100;
private const int UnverifiedCompanyPenalty = 10;
private const int GapMonthPenalty = 1;
private const int MaxGapPenalty = 10;
private const int OverlapMonthPenalty = 2;
public ProcessCVCheckJob(
ApplicationDbContext dbContext,
IFileStorageService fileStorageService,
ICVParserService cvParserService,
ICompanyVerifierService companyVerifierService,
ITimelineAnalyserService timelineAnalyserService,
ILogger<ProcessCVCheckJob> logger)
{
_dbContext = dbContext;
_fileStorageService = fileStorageService;
_cvParserService = cvParserService;
_companyVerifierService = companyVerifierService;
_timelineAnalyserService = timelineAnalyserService;
_logger = logger;
}
public async Task ExecuteAsync(Guid cvCheckId, CancellationToken cancellationToken)
{
_logger.LogInformation("Starting CV check processing for: {CheckId}", cvCheckId);
var cvCheck = await _dbContext.CVChecks
.FirstOrDefaultAsync(c => c.Id == cvCheckId, cancellationToken);
if (cvCheck is null)
{
_logger.LogError("CV check not found: {CheckId}", cvCheckId);
return;
}
try
{
// Step 1: Update status to Processing
cvCheck.Status = CheckStatus.Processing;
await _dbContext.SaveChangesAsync(cancellationToken);
_logger.LogDebug("CV check {CheckId} status updated to Processing", cvCheckId);
// Step 2: Download file from blob
await using var fileStream = await _fileStorageService.DownloadAsync(cvCheck.BlobUrl);
_logger.LogDebug("Downloaded CV file for check {CheckId}", cvCheckId);
// Step 3: Parse CV
var cvData = await _cvParserService.ParseAsync(fileStream, cvCheck.OriginalFileName);
_logger.LogDebug(
"Parsed CV for check {CheckId}: {EmploymentCount} employment entries",
cvCheckId, cvData.Employment.Count);
// Step 4: Save extracted data
cvCheck.ExtractedDataJson = JsonSerializer.Serialize(cvData, JsonOptions);
await _dbContext.SaveChangesAsync(cancellationToken);
// Step 5: Verify each employment entry
var verificationResults = new List<CompanyVerificationResult>();
foreach (var employment in cvData.Employment)
{
var result = await _companyVerifierService.VerifyCompanyAsync(
employment.CompanyName,
employment.StartDate,
employment.EndDate);
verificationResults.Add(result);
_logger.LogDebug(
"Verified {Company}: {IsVerified} (Score: {Score}%)",
employment.CompanyName, result.IsVerified, result.MatchScore);
}
// Step 6: Analyse timeline
var timelineAnalysis = _timelineAnalyserService.Analyse(cvData.Employment);
_logger.LogDebug(
"Timeline analysis for check {CheckId}: {GapCount} gaps, {OverlapCount} overlaps",
cvCheckId, timelineAnalysis.Gaps.Count, timelineAnalysis.Overlaps.Count);
// Step 7: Calculate veracity score
var (score, flags) = CalculateVeracityScore(verificationResults, timelineAnalysis);
_logger.LogDebug("Calculated veracity score for check {CheckId}: {Score}", cvCheckId, score);
// Step 8: Create CVFlag records
foreach (var flag in flags)
{
var cvFlag = new CVFlag
{
Id = Guid.NewGuid(),
CVCheckId = cvCheckId,
Category = Enum.Parse<FlagCategory>(flag.Category),
Severity = Enum.Parse<FlagSeverity>(flag.Severity),
Title = flag.Title,
Description = flag.Description,
ScoreImpact = flag.ScoreImpact
};
_dbContext.CVFlags.Add(cvFlag);
}
// Step 9: Generate veracity report
var report = new VeracityReport
{
OverallScore = score,
ScoreLabel = GetScoreLabel(score),
EmploymentVerifications = verificationResults,
TimelineAnalysis = timelineAnalysis,
Flags = flags,
GeneratedAt = DateTime.UtcNow
};
cvCheck.ReportJson = JsonSerializer.Serialize(report, JsonOptions);
cvCheck.VeracityScore = score;
// Step 10: Update status to Completed
cvCheck.Status = CheckStatus.Completed;
cvCheck.CompletedAt = DateTime.UtcNow;
await _dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"CV check {CheckId} completed successfully with score {Score}",
cvCheckId, score);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing CV check {CheckId}", cvCheckId);
cvCheck.Status = CheckStatus.Failed;
await _dbContext.SaveChangesAsync(cancellationToken);
throw;
}
}
private static (int Score, List<FlagResult> Flags) CalculateVeracityScore(
List<CompanyVerificationResult> verifications,
TimelineAnalysisResult timeline)
{
var score = BaseScore;
var flags = new List<FlagResult>();
// Penalty for unverified companies
foreach (var verification in verifications.Where(v => !v.IsVerified))
{
score -= UnverifiedCompanyPenalty;
flags.Add(new FlagResult
{
Category = FlagCategory.Employment.ToString(),
Severity = FlagSeverity.Warning.ToString(),
Title = "Unverified Company",
Description = $"Could not verify employment at '{verification.ClaimedCompany}'. {verification.VerificationNotes}",
ScoreImpact = -UnverifiedCompanyPenalty
});
}
// Penalty for gaps (max -10 per gap)
foreach (var gap in timeline.Gaps)
{
var gapPenalty = Math.Min(gap.Months * GapMonthPenalty, MaxGapPenalty);
score -= gapPenalty;
var severity = gap.Months >= 6 ? FlagSeverity.Warning : FlagSeverity.Info;
flags.Add(new FlagResult
{
Category = FlagCategory.Timeline.ToString(),
Severity = severity.ToString(),
Title = "Employment Gap",
Description = $"{gap.Months} month gap in employment from {gap.StartDate:MMM yyyy} to {gap.EndDate:MMM yyyy}",
ScoreImpact = -gapPenalty
});
}
// Penalty for overlaps (only if > 2 months)
foreach (var overlap in timeline.Overlaps)
{
var excessMonths = overlap.Months - 2; // Allow 2 month transition
var overlapPenalty = excessMonths * OverlapMonthPenalty;
score -= overlapPenalty;
var severity = overlap.Months >= 6 ? FlagSeverity.Critical : FlagSeverity.Warning;
flags.Add(new FlagResult
{
Category = FlagCategory.Timeline.ToString(),
Severity = severity.ToString(),
Title = "Employment Overlap",
Description = $"{overlap.Months} month overlap between '{overlap.Company1}' and '{overlap.Company2}' ({overlap.OverlapStart:MMM yyyy} to {overlap.OverlapEnd:MMM yyyy})",
ScoreImpact = -overlapPenalty
});
}
// Ensure score doesn't go below 0
score = Math.Max(0, score);
return (score, flags);
}
private static string GetScoreLabel(int score)
{
return score switch
{
>= 90 => "Excellent",
>= 75 => "Good",
>= 60 => "Fair",
>= 40 => "Poor",
_ => "Very Poor"
};
}
}

View File

@@ -0,0 +1,164 @@
using System.Text.Json;
using Hangfire;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using TrueCV.Application.DTOs;
using TrueCV.Application.Interfaces;
using TrueCV.Application.Models;
using TrueCV.Domain.Entities;
using TrueCV.Domain.Enums;
using TrueCV.Infrastructure.Data;
using TrueCV.Infrastructure.Jobs;
namespace TrueCV.Infrastructure.Services;
public sealed class CVCheckService : ICVCheckService
{
private readonly ApplicationDbContext _dbContext;
private readonly IFileStorageService _fileStorageService;
private readonly IBackgroundJobClient _backgroundJobClient;
private readonly ILogger<CVCheckService> _logger;
public CVCheckService(
ApplicationDbContext dbContext,
IFileStorageService fileStorageService,
IBackgroundJobClient backgroundJobClient,
ILogger<CVCheckService> logger)
{
_dbContext = dbContext;
_fileStorageService = fileStorageService;
_backgroundJobClient = backgroundJobClient;
_logger = logger;
}
public async Task<Guid> CreateCheckAsync(Guid userId, Stream file, string fileName)
{
ArgumentNullException.ThrowIfNull(file);
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
_logger.LogDebug("Creating CV check for user {UserId}, file: {FileName}", userId, fileName);
// Upload file to blob storage
var blobUrl = await _fileStorageService.UploadAsync(file, fileName);
_logger.LogDebug("File uploaded to: {BlobUrl}", blobUrl);
// Create CV check record
var cvCheck = new CVCheck
{
Id = Guid.NewGuid(),
UserId = userId,
OriginalFileName = fileName,
BlobUrl = blobUrl,
Status = CheckStatus.Pending
};
_dbContext.CVChecks.Add(cvCheck);
await _dbContext.SaveChangesAsync();
_logger.LogDebug("CV check record created with ID: {CheckId}", cvCheck.Id);
// Queue background job for processing
_backgroundJobClient.Enqueue<ProcessCVCheckJob>(job => job.ExecuteAsync(cvCheck.Id, CancellationToken.None));
_logger.LogInformation(
"CV check {CheckId} created for user {UserId}, processing queued",
cvCheck.Id, userId);
return cvCheck.Id;
}
public async Task<CVCheckDto?> GetCheckAsync(Guid id)
{
_logger.LogDebug("Retrieving CV check: {CheckId}", id);
var cvCheck = await _dbContext.CVChecks
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == id);
if (cvCheck is null)
{
_logger.LogDebug("CV check not found: {CheckId}", id);
return null;
}
return MapToDto(cvCheck);
}
public async Task<List<CVCheckDto>> GetUserChecksAsync(Guid userId)
{
_logger.LogDebug("Retrieving CV checks for user: {UserId}", userId);
var checks = await _dbContext.CVChecks
.AsNoTracking()
.Where(c => c.UserId == userId)
.OrderByDescending(c => c.CreatedAt)
.ToListAsync();
_logger.LogDebug("Found {Count} CV checks for user {UserId}", checks.Count, userId);
return checks.Select(MapToDto).ToList();
}
public async Task<CVCheckDto?> GetCheckForUserAsync(Guid id, Guid userId)
{
_logger.LogDebug("Retrieving CV check {CheckId} for user {UserId}", id, userId);
var cvCheck = await _dbContext.CVChecks
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId);
if (cvCheck is null)
{
_logger.LogDebug("CV check not found: {CheckId} for user {UserId}", id, userId);
return null;
}
return MapToDto(cvCheck);
}
public async Task<VeracityReport?> GetReportAsync(Guid checkId, Guid userId)
{
_logger.LogDebug("Retrieving report for CV check {CheckId}, user {UserId}", checkId, userId);
var cvCheck = await _dbContext.CVChecks
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == checkId && c.UserId == userId);
if (cvCheck is null)
{
_logger.LogWarning("CV check not found: {CheckId} for user {UserId}", checkId, userId);
return null;
}
if (cvCheck.Status != CheckStatus.Completed || string.IsNullOrEmpty(cvCheck.ReportJson))
{
_logger.LogDebug("CV check {CheckId} not completed or has no report", checkId);
return null;
}
try
{
var report = JsonSerializer.Deserialize<VeracityReport>(cvCheck.ReportJson);
return report;
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to deserialize report JSON for check {CheckId}", checkId);
return null;
}
}
private static CVCheckDto MapToDto(CVCheck cvCheck)
{
return new CVCheckDto
{
Id = cvCheck.Id,
OriginalFileName = cvCheck.OriginalFileName,
Status = cvCheck.Status.ToString(),
VeracityScore = cvCheck.VeracityScore,
CreatedAt = cvCheck.CreatedAt,
CompletedAt = cvCheck.CompletedAt
};
}
}

View File

@@ -0,0 +1,318 @@
using System.Text;
using System.Text.Json;
using Anthropic.SDK;
using Anthropic.SDK.Messaging;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TrueCV.Application.Interfaces;
using TrueCV.Application.Models;
using TrueCV.Infrastructure.Configuration;
using UglyToad.PdfPig;
namespace TrueCV.Infrastructure.Services;
public sealed class CVParserService : ICVParserService
{
private readonly AnthropicClient _anthropicClient;
private readonly ILogger<CVParserService> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
private const string SystemPrompt = """
You are a CV/Resume parser. Your task is to extract structured information from CV text.
You must respond ONLY with valid JSON, no other text or markdown.
""";
private const string ExtractionPrompt = """
Parse the following CV text and extract the information into this exact JSON structure:
{
"fullName": "string (required)",
"email": "string or null",
"phone": "string or null",
"employment": [
{
"companyName": "string (required)",
"jobTitle": "string (required)",
"location": "string or null",
"startDate": "YYYY-MM-DD or null",
"endDate": "YYYY-MM-DD or null (null if current)",
"isCurrent": "boolean",
"description": "string or null"
}
],
"education": [
{
"institution": "string (required)",
"qualification": "string or null (e.g., BSc, MSc, PhD)",
"subject": "string or null",
"grade": "string or null",
"startDate": "YYYY-MM-DD or null",
"endDate": "YYYY-MM-DD or null"
}
],
"skills": ["array of skill strings"]
}
Rules:
- For dates, use the first day of the month if only month/year is given (e.g., "Jan 2020" becomes "2020-01-01")
- For dates with only year, use January 1st (e.g., "2020" becomes "2020-01-01")
- Set isCurrent to true if the job appears to be ongoing (e.g., "Present", "Current", no end date mentioned with recent start)
- Extract all employment history in chronological order
- If information is not available, use null
- Do not invent or assume information not present in the text
CV TEXT:
{CV_TEXT}
""";
public CVParserService(
IOptions<AnthropicSettings> settings,
ILogger<CVParserService> logger)
{
_logger = logger;
_anthropicClient = new AnthropicClient(settings.Value.ApiKey);
}
public async Task<CVData> ParseAsync(Stream fileStream, string fileName)
{
ArgumentNullException.ThrowIfNull(fileStream);
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
_logger.LogDebug("Parsing CV file: {FileName}", fileName);
var text = await ExtractTextAsync(fileStream, fileName);
if (string.IsNullOrWhiteSpace(text))
{
_logger.LogWarning("No text content extracted from file: {FileName}", fileName);
throw new InvalidOperationException($"Could not extract text content from file: {fileName}");
}
_logger.LogDebug("Extracted {CharCount} characters from {FileName}", text.Length, fileName);
var cvData = await ParseWithClaudeAsync(text);
_logger.LogInformation(
"Successfully parsed CV for {FullName} with {EmploymentCount} employment entries and {EducationCount} education entries",
cvData.FullName,
cvData.Employment.Count,
cvData.Education.Count);
return cvData;
}
private async Task<string> ExtractTextAsync(Stream fileStream, string fileName)
{
var extension = Path.GetExtension(fileName).ToLowerInvariant();
return extension switch
{
".pdf" => await ExtractTextFromPdfAsync(fileStream),
".docx" => ExtractTextFromDocx(fileStream),
_ => throw new NotSupportedException($"File type '{extension}' is not supported. Only PDF and DOCX files are accepted.")
};
}
private async Task<string> ExtractTextFromPdfAsync(Stream fileStream)
{
// Copy stream to memory for PdfPig (requires seekable stream)
using var memoryStream = new MemoryStream();
await fileStream.CopyToAsync(memoryStream);
memoryStream.Position = 0;
using var document = PdfDocument.Open(memoryStream);
var textBuilder = new StringBuilder();
foreach (var page in document.GetPages())
{
var pageText = page.Text;
textBuilder.AppendLine(pageText);
}
return textBuilder.ToString();
}
private static string ExtractTextFromDocx(Stream fileStream)
{
using var document = WordprocessingDocument.Open(fileStream, false);
var body = document.MainDocumentPart?.Document?.Body;
if (body is null)
{
return string.Empty;
}
var textBuilder = new StringBuilder();
foreach (var paragraph in body.Elements<Paragraph>())
{
var paragraphText = paragraph.InnerText;
if (!string.IsNullOrWhiteSpace(paragraphText))
{
textBuilder.AppendLine(paragraphText);
}
}
return textBuilder.ToString();
}
private async Task<CVData> ParseWithClaudeAsync(string cvText)
{
var prompt = ExtractionPrompt.Replace("{CV_TEXT}", cvText);
var messages = new List<Message>
{
new(RoleType.User, prompt)
};
var parameters = new MessageParameters
{
Model = "claude-sonnet-4-20250514",
MaxTokens = 4096,
Messages = messages,
System = [new SystemMessage(SystemPrompt)]
};
_logger.LogDebug("Sending CV text to Claude API for parsing");
var response = await _anthropicClient.Messages.GetClaudeMessageAsync(parameters);
var responseText = response.Content
.OfType<TextContent>()
.FirstOrDefault()?.Text;
if (string.IsNullOrWhiteSpace(responseText))
{
_logger.LogError("Claude API returned empty response");
throw new InvalidOperationException("Failed to parse CV: AI returned empty response");
}
// Clean up response - remove markdown code blocks if present
responseText = CleanJsonResponse(responseText);
_logger.LogDebug("Received response from Claude API, parsing JSON");
try
{
var parsedResponse = JsonSerializer.Deserialize<ClaudeCVResponse>(responseText, JsonOptions);
if (parsedResponse is null)
{
throw new InvalidOperationException("Failed to deserialize CV data from AI response");
}
return MapToCVData(parsedResponse);
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to parse Claude response as JSON: {Response}", responseText);
throw new InvalidOperationException("Failed to parse CV: AI returned invalid JSON", ex);
}
}
private static string CleanJsonResponse(string response)
{
var trimmed = response.Trim();
// Remove markdown code blocks
if (trimmed.StartsWith("```json", StringComparison.OrdinalIgnoreCase))
{
trimmed = trimmed[7..];
}
else if (trimmed.StartsWith("```"))
{
trimmed = trimmed[3..];
}
if (trimmed.EndsWith("```"))
{
trimmed = trimmed[..^3];
}
return trimmed.Trim();
}
private static CVData MapToCVData(ClaudeCVResponse response)
{
return new CVData
{
FullName = response.FullName ?? "Unknown",
Email = response.Email,
Phone = response.Phone,
Employment = response.Employment?.Select(e => new EmploymentEntry
{
CompanyName = e.CompanyName ?? "Unknown Company",
JobTitle = e.JobTitle ?? "Unknown Position",
Location = e.Location,
StartDate = ParseDate(e.StartDate),
EndDate = ParseDate(e.EndDate),
IsCurrent = e.IsCurrent ?? false,
Description = e.Description
}).ToList() ?? [],
Education = response.Education?.Select(e => new EducationEntry
{
Institution = e.Institution ?? "Unknown Institution",
Qualification = e.Qualification,
Subject = e.Subject,
Grade = e.Grade,
StartDate = ParseDate(e.StartDate),
EndDate = ParseDate(e.EndDate)
}).ToList() ?? [],
Skills = response.Skills ?? []
};
}
private static DateOnly? ParseDate(string? dateString)
{
if (string.IsNullOrWhiteSpace(dateString))
{
return null;
}
if (DateOnly.TryParse(dateString, out var date))
{
return date;
}
return null;
}
// Internal DTOs for Claude response parsing
private sealed record ClaudeCVResponse
{
public string? FullName { get; init; }
public string? Email { get; init; }
public string? Phone { get; init; }
public List<ClaudeEmploymentEntry>? Employment { get; init; }
public List<ClaudeEducationEntry>? Education { get; init; }
public List<string>? Skills { get; init; }
}
private sealed record ClaudeEmploymentEntry
{
public string? CompanyName { get; init; }
public string? JobTitle { get; init; }
public string? Location { get; init; }
public string? StartDate { get; init; }
public string? EndDate { get; init; }
public bool? IsCurrent { get; init; }
public string? Description { get; init; }
}
private sealed record ClaudeEducationEntry
{
public string? Institution { get; init; }
public string? Qualification { get; init; }
public string? Subject { get; init; }
public string? Grade { get; init; }
public string? StartDate { get; init; }
public string? EndDate { get; init; }
}
}

View File

@@ -0,0 +1,247 @@
using FuzzySharp;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using TrueCV.Application.DTOs;
using TrueCV.Application.Interfaces;
using TrueCV.Application.Models;
using TrueCV.Domain.Entities;
using TrueCV.Infrastructure.Data;
using TrueCV.Infrastructure.ExternalApis;
namespace TrueCV.Infrastructure.Services;
public sealed class CompanyVerifierService : ICompanyVerifierService
{
private readonly CompaniesHouseClient _companiesHouseClient;
private readonly ApplicationDbContext _dbContext;
private readonly ILogger<CompanyVerifierService> _logger;
private const int FuzzyMatchThreshold = 70;
private const int CacheExpirationDays = 30;
public CompanyVerifierService(
CompaniesHouseClient companiesHouseClient,
ApplicationDbContext dbContext,
ILogger<CompanyVerifierService> logger)
{
_companiesHouseClient = companiesHouseClient;
_dbContext = dbContext;
_logger = logger;
}
public async Task<CompanyVerificationResult> VerifyCompanyAsync(
string companyName,
DateOnly? startDate,
DateOnly? endDate)
{
ArgumentException.ThrowIfNullOrWhiteSpace(companyName);
_logger.LogDebug("Verifying company: {CompanyName}", companyName);
// Try to find a cached match first
var cachedMatch = await FindCachedMatchAsync(companyName);
if (cachedMatch is not null)
{
_logger.LogDebug("Found cached company match for: {CompanyName}", companyName);
return CreateVerificationResult(companyName, cachedMatch, startDate, endDate);
}
// Search Companies House
try
{
var searchResponse = await _companiesHouseClient.SearchCompaniesAsync(companyName);
if (searchResponse?.Items is null || searchResponse.Items.Count == 0)
{
_logger.LogDebug("No companies found for: {CompanyName}", companyName);
return CreateUnverifiedResult(companyName, startDate, endDate, "No matching company found in Companies House");
}
// Find best fuzzy match
var bestMatch = FindBestMatch(companyName, searchResponse.Items);
if (bestMatch is null)
{
_logger.LogDebug("No fuzzy match above threshold for: {CompanyName}", companyName);
return CreateUnverifiedResult(companyName, startDate, endDate,
$"No company name matched above {FuzzyMatchThreshold}% threshold");
}
// Cache the matched company
var match = bestMatch.Value;
await CacheCompanyAsync(match.Item);
_logger.LogInformation(
"Verified company {ClaimedName} matched to {MatchedName} with score {Score}%",
companyName, match.Item.Title, match.Score);
return new CompanyVerificationResult
{
ClaimedCompany = companyName,
MatchedCompanyName = match.Item.Title,
MatchedCompanyNumber = match.Item.CompanyNumber,
MatchScore = match.Score,
IsVerified = true,
VerificationNotes = $"Matched with {match.Score}% confidence",
ClaimedStartDate = startDate,
ClaimedEndDate = endDate
};
}
catch (CompaniesHouseRateLimitException ex)
{
_logger.LogWarning(ex, "Rate limit hit while verifying company: {CompanyName}", companyName);
return CreateUnverifiedResult(companyName, startDate, endDate,
"Verification temporarily unavailable due to rate limiting");
}
}
public async Task<List<CompanySearchResult>> SearchCompaniesAsync(string query)
{
ArgumentException.ThrowIfNullOrWhiteSpace(query);
_logger.LogDebug("Searching companies for query: {Query}", query);
var response = await _companiesHouseClient.SearchCompaniesAsync(query);
if (response?.Items is null)
{
return [];
}
return response.Items.Select(item => new CompanySearchResult
{
CompanyNumber = item.CompanyNumber,
CompanyName = item.Title,
CompanyStatus = item.CompanyStatus ?? "Unknown",
IncorporationDate = ParseDate(item.DateOfCreation),
AddressSnippet = item.AddressSnippet
}).ToList();
}
private async Task<CompanyCache?> FindCachedMatchAsync(string companyName)
{
var cutoffDate = DateTime.UtcNow.AddDays(-CacheExpirationDays);
// Get recent cached companies
var cachedCompanies = await _dbContext.CompanyCache
.Where(c => c.CachedAt >= cutoffDate)
.ToListAsync();
if (cachedCompanies.Count == 0)
{
return null;
}
// Find best fuzzy match in cache
var matches = cachedCompanies
.Select(c => new { Company = c, Score = Fuzz.Ratio(companyName.ToUpperInvariant(), c.CompanyName.ToUpperInvariant()) })
.Where(m => m.Score >= FuzzyMatchThreshold)
.OrderByDescending(m => m.Score)
.FirstOrDefault();
return matches?.Company;
}
private static (CompaniesHouseSearchItem Item, int Score)? FindBestMatch(
string companyName,
List<CompaniesHouseSearchItem> items)
{
var normalizedSearch = companyName.ToUpperInvariant();
var matches = items
.Select(item => (Item: item, Score: Fuzz.Ratio(normalizedSearch, item.Title.ToUpperInvariant())))
.Where(m => m.Score >= FuzzyMatchThreshold)
.OrderByDescending(m => m.Score)
.ToList();
return matches.Count > 0 ? matches[0] : null;
}
private async Task CacheCompanyAsync(CompaniesHouseSearchItem item)
{
var existingCache = await _dbContext.CompanyCache
.FirstOrDefaultAsync(c => c.CompanyNumber == item.CompanyNumber);
if (existingCache is not null)
{
existingCache.CompanyName = item.Title;
existingCache.Status = item.CompanyStatus ?? "Unknown";
existingCache.IncorporationDate = ParseDate(item.DateOfCreation);
existingCache.DissolutionDate = ParseDate(item.DateOfCessation);
existingCache.CachedAt = DateTime.UtcNow;
}
else
{
var cacheEntry = new CompanyCache
{
CompanyNumber = item.CompanyNumber,
CompanyName = item.Title,
Status = item.CompanyStatus ?? "Unknown",
IncorporationDate = ParseDate(item.DateOfCreation),
DissolutionDate = ParseDate(item.DateOfCessation),
CachedAt = DateTime.UtcNow
};
_dbContext.CompanyCache.Add(cacheEntry);
}
await _dbContext.SaveChangesAsync();
}
private static CompanyVerificationResult CreateVerificationResult(
string claimedCompany,
CompanyCache cached,
DateOnly? startDate,
DateOnly? endDate)
{
var matchScore = Fuzz.Ratio(
claimedCompany.ToUpperInvariant(),
cached.CompanyName.ToUpperInvariant());
return new CompanyVerificationResult
{
ClaimedCompany = claimedCompany,
MatchedCompanyName = cached.CompanyName,
MatchedCompanyNumber = cached.CompanyNumber,
MatchScore = matchScore,
IsVerified = true,
VerificationNotes = $"Matched from cache with {matchScore}% confidence",
ClaimedStartDate = startDate,
ClaimedEndDate = endDate
};
}
private static CompanyVerificationResult CreateUnverifiedResult(
string companyName,
DateOnly? startDate,
DateOnly? endDate,
string reason)
{
return new CompanyVerificationResult
{
ClaimedCompany = companyName,
MatchedCompanyName = null,
MatchedCompanyNumber = null,
MatchScore = 0,
IsVerified = false,
VerificationNotes = reason,
ClaimedStartDate = startDate,
ClaimedEndDate = endDate
};
}
private static DateOnly? ParseDate(string? dateString)
{
if (string.IsNullOrWhiteSpace(dateString))
{
return null;
}
if (DateOnly.TryParse(dateString, out var date))
{
return date;
}
return null;
}
}

View File

@@ -0,0 +1,120 @@
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TrueCV.Application.Interfaces;
using TrueCV.Infrastructure.Configuration;
namespace TrueCV.Infrastructure.Services;
public sealed class FileStorageService : IFileStorageService
{
private readonly BlobContainerClient _containerClient;
private readonly ILogger<FileStorageService> _logger;
public FileStorageService(
IOptions<AzureBlobSettings> settings,
ILogger<FileStorageService> logger)
{
_logger = logger;
var blobServiceClient = new BlobServiceClient(settings.Value.ConnectionString);
_containerClient = blobServiceClient.GetBlobContainerClient(settings.Value.ContainerName);
}
public async Task<string> UploadAsync(Stream fileStream, string fileName)
{
ArgumentNullException.ThrowIfNull(fileStream);
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
var extension = Path.GetExtension(fileName);
var uniqueBlobName = $"{Guid.NewGuid()}{extension}";
_logger.LogDebug("Uploading file {FileName} as blob {BlobName}", fileName, uniqueBlobName);
var blobClient = _containerClient.GetBlobClient(uniqueBlobName);
await _containerClient.CreateIfNotExistsAsync();
var httpHeaders = new BlobHttpHeaders
{
ContentType = GetContentType(extension)
};
await blobClient.UploadAsync(fileStream, new BlobUploadOptions
{
HttpHeaders = httpHeaders,
Metadata = new Dictionary<string, string>
{
["originalFileName"] = fileName,
["uploadedAt"] = DateTime.UtcNow.ToString("O")
}
});
var blobUrl = blobClient.Uri.ToString();
_logger.LogInformation("Successfully uploaded file {FileName} to {BlobUrl}", fileName, blobUrl);
return blobUrl;
}
public async Task<Stream> DownloadAsync(string blobUrl)
{
ArgumentException.ThrowIfNullOrWhiteSpace(blobUrl);
var blobName = ExtractBlobNameFromUrl(blobUrl);
_logger.LogDebug("Downloading blob {BlobName} from {BlobUrl}", blobName, blobUrl);
var blobClient = _containerClient.GetBlobClient(blobName);
var response = await blobClient.DownloadStreamingAsync();
_logger.LogDebug("Successfully downloaded blob {BlobName}", blobName);
return response.Value.Content;
}
public async Task DeleteAsync(string blobUrl)
{
ArgumentException.ThrowIfNullOrWhiteSpace(blobUrl);
var blobName = ExtractBlobNameFromUrl(blobUrl);
_logger.LogDebug("Deleting blob {BlobName}", blobName);
var blobClient = _containerClient.GetBlobClient(blobName);
var deleted = await blobClient.DeleteIfExistsAsync();
if (deleted)
{
_logger.LogInformation("Successfully deleted blob {BlobName}", blobName);
}
else
{
_logger.LogWarning("Blob {BlobName} did not exist when attempting to delete", blobName);
}
}
private static string ExtractBlobNameFromUrl(string blobUrl)
{
var uri = new Uri(blobUrl);
var segments = uri.Segments;
// The blob name is the last segment after the container name
// URL format: https://account.blob.core.windows.net/container/blobname
return segments.Length > 2 ? segments[^1] : throw new ArgumentException("Invalid blob URL", nameof(blobUrl));
}
private static string GetContentType(string extension)
{
return extension.ToLowerInvariant() switch
{
".pdf" => "application/pdf",
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".doc" => "application/msword",
_ => "application/octet-stream"
};
}
}

View 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);
}
}

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\TrueCV.Application\TrueCV.Application.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Anthropic.SDK" Version="5.8.0" />
<PackageReference Include="Azure.Storage.Blobs" Version="12.27.0" />
<PackageReference Include="DocumentFormat.OpenXml" Version="3.4.1" />
<PackageReference Include="FuzzySharp" Version="2.0.2" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.*" />
<PackageReference Include="Hangfire.SqlServer" Version="1.8.*" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.*" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.*" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="UglyToad.PdfPig" Version="1.7.0-custom-5" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>