11 Commits

Author SHA1 Message Date
fab1866fc8 feat: Detect fake UK universities using naming patterns
Add detection for institutions that follow UK university naming
conventions (e.g., "University of the Peak District") but aren't
in the recognised institutions list. These are now flagged as
"Suspicious" with a -15 point penalty instead of just "Unknown".

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 08:11:08 +00:00
0dc03dd380 feat: Add Terms of Service acceptance checkbox to registration
- Add checkbox requiring users to agree to Terms of Service and Privacy Policy
- Add TermsAcceptedAt field to ApplicationUser to track acceptance
- Link checkbox to actual /terms and /privacy pages
- Remove passive text that was using dead # links

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 07:46:16 +00:00
0c42842655 feat: Add legal compliance changes
- Replace 'diploma mill' language with objective 'unaccredited institution' terminology
- Rename DiplomaMills.cs to UnaccreditedInstitutions.cs with neutral language
- Update EducationVerificationResult.IsUnaccredited property
- Update flag titles to 'Unaccredited Institution' and 'Institution Requires Verification'
- Add legal disclaimer to verification report page
- Add Privacy Policy page (/privacy) with UK GDPR compliance info
- Add Terms of Service page (/terms) with candidate notice requirements
- Add footer links to Privacy and Terms pages
- Update all tests to use new terminology

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 04:52:12 +00:00
49e4f74768 chore: Restore deploy-local.sh script
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 04:33:07 +00:00
2575e2be95 feat: Add text analysis checks for CV verification
Implement four new CV verification checks without external APIs:

1. Buzzword detection - flags excessive clichés (50+ patterns)
2. Vague achievement detection - identifies weak language vs quantified results
3. Skills/job title alignment - checks skills match claimed roles (25+ role mappings)
4. Unrealistic metrics detection - flags implausible claims (>200% growth, etc.)

New files:
- ITextAnalysisService interface
- TextAnalysisResult models
- TextAnalysisService implementation (~400 lines)

Integration:
- Added "Analysing Content" processing stage
- Flags appear under Plausibility category
- TextAnalysis section added to veracity report

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 04:30:11 +00:00
a132efd907 refactor: Remove redundant code and consolidate JsonSerializerOptions
- Remove unused GetRepoLanguagesAsync method from GitHubClient
- Remove unused IsFakeAccreditor and FakeAccreditors from DiplomaMills
- Remove unused CompanyVerificationFlagPenalty constant from ProcessCVCheckJob
- Remove unused SkillVerification properties (TotalLinesOfCode, FirstUsed, LastUsed)
- Remove unused CompanyMatchRequest record from SemanticMatchResult
- Add JsonDefaults.ApiClient and consolidate duplicate JsonSerializerOptions across API clients
- Remove ApiTester tool containing hardcoded API credentials (security fix)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 04:16:51 +00:00
f775164212 refactor: Compact UI and fix RealCV branding
- Fix logo to show RealCV instead of TrueCV
- Remove duplicate auth-logo from Login/Register pages
- Dashboard: reduce padding, smaller icons, tighter table rows
- Report: compact score header (140px → 100px roundel)
- Reduce stat card sizes and spacing throughout

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 20:56:04 +00:00
72b7f11c41 refactor: Remove SRA integration (no public API available)
The SRA (Solicitors Regulation Authority) does not provide a public REST API.
Their register is only accessible via their website. Removed all SRA-related
code and added ApiTester tool for testing remaining integrations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 15:28:07 +00:00
ff09524503 Merge branch 'feature/additional-verification-apis'
Add free verification APIs: FCA, SRA, GitHub, ORCID

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 15:09:40 +00:00
9ec96d4af7 chore: Remove OpenCorporates integration (requires paid plan for commercial use)
Keep only the genuinely free APIs:
- FCA Register (free with registration)
- SRA Register (free public API)
- GitHub (free tier sufficient)
- ORCID (free public API)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 15:09:34 +00:00
5d2965beae feat: Add additional verification APIs (FCA, SRA, GitHub, OpenCorporates, ORCID)
This adds five new free API integrations for enhanced CV verification:

- FCA Register API: Verify financial services professionals
- SRA Register API: Verify solicitors and legal professionals
- GitHub API: Verify developer profiles and technical skills
- OpenCorporates API: Verify international companies across jurisdictions
- ORCID API: Verify academic researchers and publications

Includes:
- API clients for all five services with retry policies
- Service implementations with name matching and validation
- Models for verification results with detailed flags
- Configuration options in appsettings.json
- DI registration for all services

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 13:05:52 +00:00
49 changed files with 3965 additions and 159 deletions

31
deploy-local.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/bin/bash
# Deploy RealCV from local git repo to website
set -e
cd /git/RealCV
echo "Building application..."
dotnet publish src/RealCV.Web -c Release -o ./publish --nologo
echo "Stopping service..."
sudo systemctl stop realcv
echo "Backing up config..."
cp /var/www/realcv/appsettings.Production.json /tmp/appsettings.Production.json 2>/dev/null || true
echo "Deploying files..."
sudo rm -rf /var/www/realcv/*
sudo cp -r ./publish/* /var/www/realcv/
echo "Restoring config..."
sudo cp /tmp/appsettings.Production.json /var/www/realcv/ 2>/dev/null || true
echo "Setting permissions..."
sudo chown -R www-data:www-data /var/www/realcv
echo "Starting service..."
sudo systemctl start realcv
echo "Done! Checking status..."
sleep 2
sudo systemctl is-active realcv && echo "Service is running."

BIN
screenshots/01-home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 646 KiB

BIN
screenshots/02-login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 433 KiB

BIN
screenshots/03-register.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

BIN
screenshots/05-check.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

BIN
screenshots/06-report.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

View File

@@ -1,18 +1,18 @@
namespace RealCV.Application.Data; namespace RealCV.Application.Data;
/// <summary> /// <summary>
/// Known diploma mills and fake educational institutions. /// Institutions not recognised by UK higher education regulatory bodies.
/// Sources: HEDD, Oregon ODA, UNESCO warnings, Michigan AG list /// Sources: HEDD, Oregon ODA, UNESCO warnings, Michigan AG list
/// </summary> /// </summary>
public static class DiplomaMills public static class UnaccreditedInstitutions
{ {
/// <summary> /// <summary>
/// Known diploma mills and unaccredited institutions that sell fake degrees. /// Institutions identified by regulatory bodies as not meeting recognised accreditation standards.
/// This list includes institutions identified by various regulatory bodies. /// This list includes institutions flagged by various educational oversight organisations.
/// </summary> /// </summary>
public static readonly HashSet<string> KnownDiplomaMills = new(StringComparer.OrdinalIgnoreCase) public static readonly HashSet<string> KnownUnaccredited = new(StringComparer.OrdinalIgnoreCase)
{ {
// Well-known diploma mills // Institutions not meeting accreditation standards
"Almeda University", "Almeda University",
"Ashwood University", "Ashwood University",
"Belford University", "Belford University",
@@ -67,7 +67,7 @@ public static class DiplomaMills
"Stanton University", "Stanton University",
"Stratford University (if unaccredited)", "Stratford University (if unaccredited)",
"Suffield University", "Suffield University",
"Summit University (diploma mill)", "Summit University (unaccredited)",
"Sussex College of Technology", "Sussex College of Technology",
"Trinity College and University", "Trinity College and University",
"Trinity Southern University", "Trinity Southern University",
@@ -80,7 +80,7 @@ public static class DiplomaMills
"University of Northern Washington", "University of Northern Washington",
"University of Palmers Green", "University of Palmers Green",
"University of San Moritz", "University of San Moritz",
"University of Sussex (fake - not real Sussex)", "University of Sussex (not the legitimate University of Sussex)",
"University of Wexford", "University of Wexford",
"Vocational University", "Vocational University",
"Warnborough University", "Warnborough University",
@@ -91,7 +91,7 @@ public static class DiplomaMills
"Woodfield University", "Woodfield University",
"Yorker International University", "Yorker International University",
// Pakistani diploma mills commonly seen in UK // Unaccredited institutions commonly seen in UK applications
"Axact University", "Axact University",
"Brooklyn Park University", "Brooklyn Park University",
"Columbiana University", "Columbiana University",
@@ -100,11 +100,11 @@ public static class DiplomaMills
"Oxbridge University", "Oxbridge University",
"University of Newford", "University of Newford",
// Online diploma mills // Online unaccredited institutions
"American World University", "American World University",
"Ashford University (pre-2005)", "Ashford University (pre-2005)",
"Concordia College and University", "Concordia College and University",
"Columbus State University (fake)", "Columbus State University (unaccredited variant)",
"Frederick Taylor University", "Frederick Taylor University",
"International Theological University", "International Theological University",
"Nations University", "Nations University",
@@ -115,7 +115,7 @@ public static class DiplomaMills
}; };
/// <summary> /// <summary>
/// Suspicious patterns in institution names that often indicate diploma mills. /// Patterns in institution names that may indicate unaccredited status.
/// </summary> /// </summary>
public static readonly string[] SuspiciousPatterns = public static readonly string[] SuspiciousPatterns =
[ [
@@ -136,27 +136,9 @@ public static class DiplomaMills
]; ];
/// <summary> /// <summary>
/// Fake accreditation bodies used by diploma mills. /// Check if an institution is not recognised by accreditation bodies.
/// </summary> /// </summary>
public static readonly HashSet<string> FakeAccreditors = new(StringComparer.OrdinalIgnoreCase) public static bool IsUnaccredited(string institutionName)
{
"World Association of Universities and Colleges",
"WAUC",
"International Accreditation Agency",
"Universal Accreditation Council",
"Board of Online Universities Accreditation",
"International Council for Open and Distance Education",
"World Online Education Accrediting Commission",
"Central States Consortium of Colleges and Schools",
"American Council of Private Colleges and Universities",
"Association of Distance Learning Programs",
"International Distance Education Certification Agency",
};
/// <summary>
/// Check if an institution is a known diploma mill.
/// </summary>
public static bool IsDiplomaMill(string institutionName)
{ {
if (string.IsNullOrWhiteSpace(institutionName)) if (string.IsNullOrWhiteSpace(institutionName))
return false; return false;
@@ -164,13 +146,13 @@ public static class DiplomaMills
var normalised = institutionName.Trim(); var normalised = institutionName.Trim();
// Direct match // Direct match
if (KnownDiplomaMills.Contains(normalised)) if (KnownUnaccredited.Contains(normalised))
return true; return true;
// Check if name contains known diploma mill // Check if name contains known unaccredited institution
foreach (var mill in KnownDiplomaMills) foreach (var institution in KnownUnaccredited)
{ {
if (normalised.Contains(mill, StringComparison.OrdinalIgnoreCase)) if (normalised.Contains(institution, StringComparison.OrdinalIgnoreCase))
return true; return true;
} }
@@ -178,8 +160,8 @@ public static class DiplomaMills
} }
/// <summary> /// <summary>
/// Check if institution name has suspicious patterns common in diploma mills. /// Check if institution name has patterns that may indicate unaccredited status.
/// Returns true if suspicious (but not confirmed fake). /// Returns true if patterns suggest further verification is recommended.
/// </summary> /// </summary>
public static bool HasSuspiciousPattern(string institutionName) public static bool HasSuspiciousPattern(string institutionName)
{ {
@@ -196,15 +178,4 @@ public static class DiplomaMills
return false; return false;
} }
/// <summary>
/// Check if an accreditor is known to be fake.
/// </summary>
public static bool IsFakeAccreditor(string accreditorName)
{
if (string.IsNullOrWhiteSpace(accreditorName))
return false;
return FakeAccreditors.Contains(accreditorName.Trim());
}
} }

View File

@@ -1,4 +1,5 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization;
namespace RealCV.Application.Helpers; namespace RealCV.Application.Helpers;
@@ -16,4 +17,13 @@ public static class JsonDefaults
PropertyNameCaseInsensitive = true, PropertyNameCaseInsensitive = true,
WriteIndented = true WriteIndented = true
}; };
/// <summary>
/// Options for consuming external APIs - case insensitive with null handling.
/// </summary>
public static readonly JsonSerializerOptions ApiClient = new()
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
} }

View File

@@ -0,0 +1,54 @@
using RealCV.Application.Models;
namespace RealCV.Application.Interfaces;
/// <summary>
/// Service for verifying academic researchers via ORCID
/// </summary>
public interface IAcademicVerifierService
{
/// <summary>
/// Verify an academic researcher by ORCID ID
/// </summary>
Task<AcademicVerificationResult> VerifyByOrcidAsync(string orcidId);
/// <summary>
/// Search for researchers and verify by name
/// </summary>
Task<AcademicVerificationResult> VerifyByNameAsync(
string name,
string? affiliation = null);
/// <summary>
/// Search ORCID for researchers
/// </summary>
Task<List<OrcidSearchResult>> SearchResearchersAsync(
string name,
string? affiliation = null);
/// <summary>
/// Verify claimed publications
/// </summary>
Task<List<PublicationVerificationResult>> VerifyPublicationsAsync(
string orcidId,
List<string> claimedPublications);
}
public sealed record OrcidSearchResult
{
public required string OrcidId { get; init; }
public required string Name { get; init; }
public string? OrcidUrl { get; init; }
public List<string>? Affiliations { get; init; }
public int? PublicationCount { get; init; }
}
public sealed record PublicationVerificationResult
{
public required string ClaimedTitle { get; init; }
public required bool IsVerified { get; init; }
public string? MatchedTitle { get; init; }
public string? Doi { get; init; }
public int? Year { get; init; }
public string? Notes { get; init; }
}

View File

@@ -0,0 +1,36 @@
using RealCV.Application.Models;
namespace RealCV.Application.Interfaces;
/// <summary>
/// Service for verifying developer profiles and skills via GitHub
/// </summary>
public interface IGitHubVerifierService
{
/// <summary>
/// Verify a GitHub profile and analyze activity
/// </summary>
Task<GitHubVerificationResult> VerifyProfileAsync(string username);
/// <summary>
/// Verify claimed programming skills against GitHub activity
/// </summary>
Task<GitHubVerificationResult> VerifySkillsAsync(
string username,
List<string> claimedSkills);
/// <summary>
/// Search for GitHub profiles matching a name
/// </summary>
Task<List<GitHubProfileSearchResult>> SearchProfilesAsync(string name);
}
public sealed record GitHubProfileSearchResult
{
public required string Username { get; init; }
public string? Name { get; init; }
public string? AvatarUrl { get; init; }
public string? Bio { get; init; }
public int PublicRepos { get; init; }
public int Followers { get; init; }
}

View File

@@ -0,0 +1,30 @@
using RealCV.Application.Models;
namespace RealCV.Application.Interfaces;
/// <summary>
/// Service for verifying professional qualifications (FCA)
/// </summary>
public interface IProfessionalVerifierService
{
/// <summary>
/// Verify if a person is registered with the FCA
/// </summary>
Task<ProfessionalVerificationResult> VerifyFcaRegistrationAsync(
string name,
string? firmName = null,
string? referenceNumber = null);
/// <summary>
/// Search FCA register for individuals
/// </summary>
Task<List<FcaIndividualSearchResult>> SearchFcaIndividualsAsync(string name);
}
public sealed record FcaIndividualSearchResult
{
public required string Name { get; init; }
public required string IndividualReferenceNumber { get; init; }
public string? Status { get; init; }
public List<string>? CurrentFirms { get; init; }
}

View File

@@ -0,0 +1,8 @@
using RealCV.Application.Models;
namespace RealCV.Application.Interfaces;
public interface ITextAnalysisService
{
TextAnalysisResult Analyse(CVData cvData);
}

View File

@@ -0,0 +1,62 @@
namespace RealCV.Application.Models;
/// <summary>
/// Result of verifying an academic researcher via ORCID
/// </summary>
public sealed record AcademicVerificationResult
{
public required string ClaimedName { get; init; }
public required bool IsVerified { get; init; }
// ORCID profile
public string? OrcidId { get; init; }
public string? MatchedName { get; init; }
public string? OrcidUrl { get; init; }
// Academic affiliations
public List<AcademicAffiliation> Affiliations { get; init; } = [];
// Publications
public int TotalPublications { get; init; }
public List<Publication> RecentPublications { get; init; } = [];
// Education from ORCID
public List<AcademicEducation> Education { get; init; } = [];
public string? VerificationNotes { get; init; }
public List<AcademicVerificationFlag> Flags { get; init; } = [];
}
public sealed record AcademicAffiliation
{
public required string Organization { get; init; }
public string? Department { get; init; }
public string? Role { get; init; }
public DateOnly? StartDate { get; init; }
public DateOnly? EndDate { get; init; }
}
public sealed record Publication
{
public required string Title { get; init; }
public string? Journal { get; init; }
public int? Year { get; init; }
public string? Doi { get; init; }
public string? Type { get; init; }
}
public sealed record AcademicEducation
{
public required string Institution { get; init; }
public string? Degree { get; init; }
public string? Subject { get; init; }
public int? Year { get; init; }
}
public sealed record AcademicVerificationFlag
{
public required string Type { get; init; }
public required string Severity { get; init; }
public required string Message { get; init; }
public int ScoreImpact { get; init; }
}

View File

@@ -4,9 +4,9 @@ public sealed record EducationVerificationResult
{ {
public required string ClaimedInstitution { get; init; } public required string ClaimedInstitution { get; init; }
public string? MatchedInstitution { get; init; } public string? MatchedInstitution { get; init; }
public required string Status { get; init; } // Recognised, NotRecognised, DiplomaMill, Suspicious, Unknown public required string Status { get; init; } // Recognised, NotRecognised, Unaccredited, Suspicious, Unknown
public bool IsVerified { get; init; } public bool IsVerified { get; init; }
public bool IsDiplomaMill { get; init; } public bool IsUnaccredited { get; init; }
public bool IsSuspicious { get; init; } public bool IsSuspicious { get; init; }
public string? VerificationNotes { get; init; } public string? VerificationNotes { get; init; }

View File

@@ -0,0 +1,49 @@
namespace RealCV.Application.Models;
/// <summary>
/// Result of verifying a developer's GitHub profile
/// </summary>
public sealed record GitHubVerificationResult
{
public required string ClaimedUsername { get; init; }
public required bool IsVerified { get; init; }
// Profile details
public string? ProfileName { get; init; }
public string? ProfileUrl { get; init; }
public string? Bio { get; init; }
public string? Company { get; init; }
public string? Location { get; init; }
public DateOnly? AccountCreated { get; init; }
// Activity metrics
public int PublicRepos { get; init; }
public int Followers { get; init; }
public int Following { get; init; }
public int TotalContributions { get; init; }
// Language breakdown
public Dictionary<string, int> LanguageStats { get; init; } = new();
// Claimed skills verification
public List<SkillVerification> SkillVerifications { get; init; } = [];
public string? VerificationNotes { get; init; }
public List<GitHubVerificationFlag> Flags { get; init; } = [];
}
public sealed record SkillVerification
{
public required string ClaimedSkill { get; init; }
public required bool IsVerified { get; init; }
public int RepoCount { get; init; }
public string? Notes { get; init; }
}
public sealed record GitHubVerificationFlag
{
public required string Type { get; init; }
public required string Severity { get; init; }
public required string Message { get; init; }
public int ScoreImpact { get; init; }
}

View File

@@ -0,0 +1,33 @@
namespace RealCV.Application.Models;
/// <summary>
/// Result of verifying a professional qualification (FCA)
/// </summary>
public sealed record ProfessionalVerificationResult
{
public required string ClaimedName { get; init; }
public required string ProfessionalBody { get; init; }
public required bool IsVerified { get; init; }
// Matched professional details
public string? MatchedName { get; init; }
public string? RegistrationNumber { get; init; }
public string? Status { get; init; }
public string? CurrentEmployer { get; init; }
public DateOnly? RegistrationDate { get; init; }
// For FCA
public List<string>? ApprovedFunctions { get; init; }
public List<string>? ControlledFunctions { get; init; }
public string? VerificationNotes { get; init; }
public List<ProfessionalVerificationFlag> Flags { get; init; } = [];
}
public sealed record ProfessionalVerificationFlag
{
public required string Type { get; init; }
public required string Severity { get; init; }
public required string Message { get; init; }
public int ScoreImpact { get; init; }
}

View File

@@ -10,12 +10,6 @@ public record SemanticMatchResult
public bool IsMatch => ConfidenceScore >= 70; public bool IsMatch => ConfidenceScore >= 70;
} }
public record CompanyMatchRequest
{
public required string CVCompanyName { get; init; }
public required List<CompanyCandidate> Candidates { get; init; }
}
public record CompanyCandidate public record CompanyCandidate
{ {
public required string CompanyName { get; init; } public required string CompanyName { get; init; }

View File

@@ -0,0 +1,66 @@
namespace RealCV.Application.Models;
public sealed record TextAnalysisResult
{
public BuzzwordAnalysis BuzzwordAnalysis { get; init; } = new();
public AchievementAnalysis AchievementAnalysis { get; init; } = new();
public SkillsAlignmentAnalysis SkillsAlignment { get; init; } = new();
public MetricsAnalysis MetricsAnalysis { get; init; } = new();
public List<TextAnalysisFlag> Flags { get; init; } = [];
}
public sealed record BuzzwordAnalysis
{
public int TotalBuzzwords { get; init; }
public List<string> BuzzwordsFound { get; init; } = [];
public double BuzzwordDensity { get; init; }
}
public sealed record AchievementAnalysis
{
public int TotalStatements { get; init; }
public int VagueStatements { get; init; }
public int QuantifiedStatements { get; init; }
public int StrongActionVerbStatements { get; init; }
public List<string> VagueExamples { get; init; } = [];
}
public sealed record SkillsAlignmentAnalysis
{
public int TotalRolesChecked { get; init; }
public int RolesWithMatchingSkills { get; init; }
public List<SkillMismatch> Mismatches { get; init; } = [];
}
public sealed record SkillMismatch
{
public required string JobTitle { get; init; }
public required string CompanyName { get; init; }
public required List<string> ExpectedSkills { get; init; }
public required List<string> MatchingSkills { get; init; }
}
public sealed record MetricsAnalysis
{
public int TotalMetricsClaimed { get; init; }
public int PlausibleMetrics { get; init; }
public int SuspiciousMetrics { get; init; }
public int RoundNumberCount { get; init; }
public double RoundNumberRatio { get; init; }
public List<SuspiciousMetric> SuspiciousMetricsList { get; init; } = [];
}
public sealed record SuspiciousMetric
{
public required string ClaimText { get; init; }
public required double Value { get; init; }
public required string Reason { get; init; }
}
public sealed record TextAnalysisFlag
{
public required string Type { get; init; }
public required string Severity { get; init; }
public required string Message { get; init; }
public int ScoreImpact { get; init; }
}

View File

@@ -8,6 +8,7 @@ public sealed record VeracityReport
public List<CompanyVerificationResult> EmploymentVerifications { get; init; } = []; public List<CompanyVerificationResult> EmploymentVerifications { get; init; } = [];
public List<EducationVerificationResult> EducationVerifications { get; init; } = []; public List<EducationVerificationResult> EducationVerifications { get; init; } = [];
public required TimelineAnalysisResult TimelineAnalysis { get; init; } public required TimelineAnalysisResult TimelineAnalysis { get; init; }
public TextAnalysisResult? TextAnalysis { get; init; }
public List<FlagResult> Flags { get; init; } = []; public List<FlagResult> Flags { get; init; } = [];
public required DateTime GeneratedAt { get; init; } public required DateTime GeneratedAt { get; init; }
} }

View File

@@ -0,0 +1,210 @@
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using RealCV.Application.Helpers;
namespace RealCV.Infrastructure.Clients;
public sealed class FcaRegisterClient
{
private readonly HttpClient _httpClient;
private readonly ILogger<FcaRegisterClient> _logger;
private readonly string _apiKey;
public FcaRegisterClient(
HttpClient httpClient,
IOptions<FcaOptions> options,
ILogger<FcaRegisterClient> logger)
{
_httpClient = httpClient;
_logger = logger;
_apiKey = options.Value.ApiKey;
_httpClient.BaseAddress = new Uri("https://register.fca.org.uk/services/V0.1/");
_httpClient.DefaultRequestHeaders.Add("X-Auth-Email", options.Value.Email);
_httpClient.DefaultRequestHeaders.Add("X-Auth-Key", _apiKey);
}
public async Task<FcaIndividualResponse?> SearchIndividualsAsync(string name, int page = 1)
{
try
{
var encodedName = Uri.EscapeDataString(name);
var url = $"Individuals?q={encodedName}&page={page}";
_logger.LogDebug("Searching FCA for individual: {Name}", name);
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("FCA API returned {StatusCode} for search: {Name}",
response.StatusCode, name);
return null;
}
return await response.Content.ReadFromJsonAsync<FcaIndividualResponse>(JsonDefaults.ApiClient);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching FCA for individual: {Name}", name);
return null;
}
}
public async Task<FcaIndividualDetails?> GetIndividualAsync(string individualReferenceNumber)
{
try
{
var url = $"Individuals/{individualReferenceNumber}";
_logger.LogDebug("Getting FCA individual: {IRN}", individualReferenceNumber);
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("FCA API returned {StatusCode} for IRN: {IRN}",
response.StatusCode, individualReferenceNumber);
return null;
}
var wrapper = await response.Content.ReadFromJsonAsync<FcaIndividualDetailsWrapper>(JsonDefaults.ApiClient);
return wrapper?.Data?.FirstOrDefault();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting FCA individual: {IRN}", individualReferenceNumber);
return null;
}
}
public async Task<FcaFirmResponse?> SearchFirmsAsync(string name, int page = 1)
{
try
{
var encodedName = Uri.EscapeDataString(name);
var url = $"Firms?q={encodedName}&page={page}";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
return null;
}
return await response.Content.ReadFromJsonAsync<FcaFirmResponse>(JsonDefaults.ApiClient);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching FCA for firm: {Name}", name);
return null;
}
}
}
public class FcaOptions
{
public string ApiKey { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
}
// Response models
public class FcaIndividualResponse
{
public List<FcaIndividualSearchItem>? Data { get; set; }
public FcaPagination? Pagination { get; set; }
}
public class FcaIndividualSearchItem
{
[JsonPropertyName("Individual Reference Number")]
public string? IndividualReferenceNumber { get; set; }
public string? Name { get; set; }
public string? Status { get; set; }
[JsonPropertyName("Current Employer(s)")]
public string? CurrentEmployers { get; set; }
}
public class FcaIndividualDetailsWrapper
{
public List<FcaIndividualDetails>? Data { get; set; }
}
public class FcaIndividualDetails
{
[JsonPropertyName("Individual Reference Number")]
public string? IndividualReferenceNumber { get; set; }
public string? Name { get; set; }
public string? Status { get; set; }
[JsonPropertyName("Effective Date")]
public string? EffectiveDate { get; set; }
[JsonPropertyName("Controlled Functions")]
public List<FcaControlledFunction>? ControlledFunctions { get; set; }
[JsonPropertyName("Previous Employments")]
public List<FcaPreviousEmployment>? PreviousEmployments { get; set; }
}
public class FcaControlledFunction
{
[JsonPropertyName("Controlled Function")]
public string? ControlledFunction { get; set; }
[JsonPropertyName("Firm Name")]
public string? FirmName { get; set; }
[JsonPropertyName("Firm Reference Number")]
public string? FirmReferenceNumber { get; set; }
[JsonPropertyName("Status")]
public string? Status { get; set; }
[JsonPropertyName("Effective From")]
public string? EffectiveFrom { get; set; }
}
public class FcaPreviousEmployment
{
[JsonPropertyName("Firm Name")]
public string? FirmName { get; set; }
[JsonPropertyName("Firm Reference Number")]
public string? FirmReferenceNumber { get; set; }
[JsonPropertyName("Start Date")]
public string? StartDate { get; set; }
[JsonPropertyName("End Date")]
public string? EndDate { get; set; }
}
public class FcaFirmResponse
{
public List<FcaFirmSearchItem>? Data { get; set; }
public FcaPagination? Pagination { get; set; }
}
public class FcaFirmSearchItem
{
[JsonPropertyName("Firm Reference Number")]
public string? FirmReferenceNumber { get; set; }
[JsonPropertyName("Firm Name")]
public string? FirmName { get; set; }
public string? Status { get; set; }
}
public class FcaPagination
{
public int Page { get; set; }
public int TotalPages { get; set; }
public int TotalItems { get; set; }
}

View File

@@ -0,0 +1,237 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using RealCV.Application.Helpers;
namespace RealCV.Infrastructure.Clients;
public sealed class GitHubApiClient
{
private readonly HttpClient _httpClient;
private readonly ILogger<GitHubApiClient> _logger;
public GitHubApiClient(
HttpClient httpClient,
IOptions<GitHubOptions> options,
ILogger<GitHubApiClient> logger)
{
_httpClient = httpClient;
_logger = logger;
_httpClient.BaseAddress = new Uri("https://api.github.com/");
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
_httpClient.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28");
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("RealCV/1.0");
if (!string.IsNullOrEmpty(options.Value.PersonalAccessToken))
{
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", options.Value.PersonalAccessToken);
}
}
public async Task<GitHubUser?> GetUserAsync(string username)
{
try
{
var url = $"users/{Uri.EscapeDataString(username)}";
_logger.LogDebug("Getting GitHub user: {Username}", username);
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("GitHub API returned {StatusCode} for user: {Username}",
response.StatusCode, username);
return null;
}
return await response.Content.ReadFromJsonAsync<GitHubUser>(JsonDefaults.ApiClient);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting GitHub user: {Username}", username);
return null;
}
}
public async Task<List<GitHubRepo>> GetUserReposAsync(string username, int perPage = 100)
{
var repos = new List<GitHubRepo>();
var page = 1;
try
{
while (true)
{
var url = $"users/{Uri.EscapeDataString(username)}/repos?per_page={perPage}&page={page}&sort=updated";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
break;
}
var pageRepos = await response.Content.ReadFromJsonAsync<List<GitHubRepo>>(JsonDefaults.ApiClient);
if (pageRepos == null || pageRepos.Count == 0)
{
break;
}
repos.AddRange(pageRepos);
if (pageRepos.Count < perPage)
{
break;
}
page++;
// Limit to avoid rate limiting
if (page > 5)
{
break;
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting repos for user: {Username}", username);
}
return repos;
}
public async Task<GitHubUserSearchResponse?> SearchUsersAsync(string query, int perPage = 30)
{
try
{
var encodedQuery = Uri.EscapeDataString(query);
var url = $"search/users?q={encodedQuery}&per_page={perPage}";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
return null;
}
return await response.Content.ReadFromJsonAsync<GitHubUserSearchResponse>(JsonDefaults.ApiClient);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching GitHub users: {Query}", query);
return null;
}
}
}
public class GitHubOptions
{
public string PersonalAccessToken { get; set; } = string.Empty;
}
// Response models
public class GitHubUser
{
public string? Login { get; set; }
public int Id { get; set; }
public string? Name { get; set; }
public string? Company { get; set; }
public string? Blog { get; set; }
public string? Location { get; set; }
public string? Email { get; set; }
public string? Bio { get; set; }
[JsonPropertyName("twitter_username")]
public string? TwitterUsername { get; set; }
[JsonPropertyName("public_repos")]
public int PublicRepos { get; set; }
[JsonPropertyName("public_gists")]
public int PublicGists { get; set; }
public int Followers { get; set; }
public int Following { get; set; }
[JsonPropertyName("created_at")]
public DateTime CreatedAt { get; set; }
[JsonPropertyName("updated_at")]
public DateTime UpdatedAt { get; set; }
[JsonPropertyName("html_url")]
public string? HtmlUrl { get; set; }
[JsonPropertyName("avatar_url")]
public string? AvatarUrl { get; set; }
}
public class GitHubRepo
{
public int Id { get; set; }
public string? Name { get; set; }
[JsonPropertyName("full_name")]
public string? FullName { get; set; }
public string? Description { get; set; }
public string? Language { get; set; }
[JsonPropertyName("html_url")]
public string? HtmlUrl { get; set; }
public bool Fork { get; set; }
public bool Private { get; set; }
[JsonPropertyName("stargazers_count")]
public int StargazersCount { get; set; }
[JsonPropertyName("watchers_count")]
public int WatchersCount { get; set; }
[JsonPropertyName("forks_count")]
public int ForksCount { get; set; }
public int Size { get; set; }
[JsonPropertyName("created_at")]
public DateTime CreatedAt { get; set; }
[JsonPropertyName("updated_at")]
public DateTime UpdatedAt { get; set; }
[JsonPropertyName("pushed_at")]
public DateTime? PushedAt { get; set; }
}
public class GitHubUserSearchResponse
{
[JsonPropertyName("total_count")]
public int TotalCount { get; set; }
[JsonPropertyName("incomplete_results")]
public bool IncompleteResults { get; set; }
public List<GitHubUserSearchItem>? Items { get; set; }
}
public class GitHubUserSearchItem
{
public string? Login { get; set; }
public int Id { get; set; }
[JsonPropertyName("avatar_url")]
public string? AvatarUrl { get; set; }
[JsonPropertyName("html_url")]
public string? HtmlUrl { get; set; }
public double Score { get; set; }
}

View File

@@ -0,0 +1,336 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using RealCV.Application.Helpers;
namespace RealCV.Infrastructure.Clients;
public sealed class OrcidClient
{
private readonly HttpClient _httpClient;
private readonly ILogger<OrcidClient> _logger;
public OrcidClient(
HttpClient httpClient,
ILogger<OrcidClient> logger)
{
_httpClient = httpClient;
_logger = logger;
_httpClient.BaseAddress = new Uri("https://pub.orcid.org/v3.0/");
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
}
public async Task<OrcidSearchResponse?> SearchResearchersAsync(string query, int start = 0, int rows = 20)
{
try
{
var encodedQuery = Uri.EscapeDataString(query);
var url = $"search?q={encodedQuery}&start={start}&rows={rows}";
_logger.LogDebug("Searching ORCID: {Query}", query);
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("ORCID API returned {StatusCode} for search: {Query}",
response.StatusCode, query);
return null;
}
return await response.Content.ReadFromJsonAsync<OrcidSearchResponse>(JsonDefaults.ApiClient);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching ORCID: {Query}", query);
return null;
}
}
public async Task<OrcidRecord?> GetRecordAsync(string orcidId)
{
try
{
// Normalize ORCID ID format (remove URL prefix if present)
orcidId = NormalizeOrcidId(orcidId);
var url = $"{orcidId}/record";
_logger.LogDebug("Getting ORCID record: {OrcidId}", orcidId);
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("ORCID API returned {StatusCode} for ID: {OrcidId}",
response.StatusCode, orcidId);
return null;
}
return await response.Content.ReadFromJsonAsync<OrcidRecord>(JsonDefaults.ApiClient);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting ORCID record: {OrcidId}", orcidId);
return null;
}
}
public async Task<OrcidWorks?> GetWorksAsync(string orcidId)
{
try
{
orcidId = NormalizeOrcidId(orcidId);
var url = $"{orcidId}/works";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
return null;
}
return await response.Content.ReadFromJsonAsync<OrcidWorks>(JsonDefaults.ApiClient);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting ORCID works: {OrcidId}", orcidId);
return null;
}
}
public async Task<OrcidEmployments?> GetEmploymentsAsync(string orcidId)
{
try
{
orcidId = NormalizeOrcidId(orcidId);
var url = $"{orcidId}/employments";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
return null;
}
return await response.Content.ReadFromJsonAsync<OrcidEmployments>(JsonDefaults.ApiClient);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting ORCID employments: {OrcidId}", orcidId);
return null;
}
}
public async Task<OrcidEducations?> GetEducationsAsync(string orcidId)
{
try
{
orcidId = NormalizeOrcidId(orcidId);
var url = $"{orcidId}/educations";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
return null;
}
return await response.Content.ReadFromJsonAsync<OrcidEducations>(JsonDefaults.ApiClient);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting ORCID educations: {OrcidId}", orcidId);
return null;
}
}
private static string NormalizeOrcidId(string orcidId)
{
// Remove URL prefixes
orcidId = orcidId.Replace("https://orcid.org/", "")
.Replace("http://orcid.org/", "")
.Trim();
return orcidId;
}
}
// Response models
public class OrcidSearchResponse
{
[JsonPropertyName("num-found")]
public int NumFound { get; set; }
public List<OrcidSearchResult>? Result { get; set; }
}
public class OrcidSearchResult
{
[JsonPropertyName("orcid-identifier")]
public OrcidIdentifier? OrcidIdentifier { get; set; }
}
public class OrcidIdentifier
{
public string? Uri { get; set; }
public string? Path { get; set; }
public string? Host { get; set; }
}
public class OrcidRecord
{
[JsonPropertyName("orcid-identifier")]
public OrcidIdentifier? OrcidIdentifier { get; set; }
public OrcidPerson? Person { get; set; }
[JsonPropertyName("activities-summary")]
public OrcidActivitiesSummary? ActivitiesSummary { get; set; }
}
public class OrcidPerson
{
public OrcidName? Name { get; set; }
public OrcidBiography? Biography { get; set; }
}
public class OrcidName
{
[JsonPropertyName("given-names")]
public OrcidValue? GivenNames { get; set; }
[JsonPropertyName("family-name")]
public OrcidValue? FamilyName { get; set; }
[JsonPropertyName("credit-name")]
public OrcidValue? CreditName { get; set; }
}
public class OrcidValue
{
public string? Value { get; set; }
}
public class OrcidBiography
{
public string? Content { get; set; }
}
public class OrcidActivitiesSummary
{
public OrcidEmployments? Employments { get; set; }
public OrcidEducations? Educations { get; set; }
public OrcidWorks? Works { get; set; }
}
public class OrcidEmployments
{
[JsonPropertyName("affiliation-group")]
public List<OrcidAffiliationGroup>? AffiliationGroup { get; set; }
}
public class OrcidEducations
{
[JsonPropertyName("affiliation-group")]
public List<OrcidAffiliationGroup>? AffiliationGroup { get; set; }
}
public class OrcidAffiliationGroup
{
public List<OrcidAffiliationSummaryWrapper>? Summaries { get; set; }
}
public class OrcidAffiliationSummaryWrapper
{
[JsonPropertyName("employment-summary")]
public OrcidAffiliationSummary? EmploymentSummary { get; set; }
[JsonPropertyName("education-summary")]
public OrcidAffiliationSummary? EducationSummary { get; set; }
}
public class OrcidAffiliationSummary
{
[JsonPropertyName("department-name")]
public string? DepartmentName { get; set; }
[JsonPropertyName("role-title")]
public string? RoleTitle { get; set; }
[JsonPropertyName("start-date")]
public OrcidDate? StartDate { get; set; }
[JsonPropertyName("end-date")]
public OrcidDate? EndDate { get; set; }
public OrcidOrganization? Organization { get; set; }
}
public class OrcidDate
{
public OrcidValue? Year { get; set; }
public OrcidValue? Month { get; set; }
public OrcidValue? Day { get; set; }
}
public class OrcidOrganization
{
public string? Name { get; set; }
public OrcidAddress? Address { get; set; }
}
public class OrcidAddress
{
public string? City { get; set; }
public string? Region { get; set; }
public string? Country { get; set; }
}
public class OrcidWorks
{
public List<OrcidWorkGroup>? Group { get; set; }
}
public class OrcidWorkGroup
{
[JsonPropertyName("work-summary")]
public List<OrcidWorkSummary>? WorkSummary { get; set; }
}
public class OrcidWorkSummary
{
public string? Title { get; set; }
public OrcidTitle? TitleObj { get; set; }
public string? Type { get; set; }
[JsonPropertyName("publication-date")]
public OrcidDate? PublicationDate { get; set; }
[JsonPropertyName("journal-title")]
public OrcidValue? JournalTitle { get; set; }
[JsonPropertyName("external-ids")]
public OrcidExternalIds? ExternalIds { get; set; }
}
public class OrcidTitle
{
public OrcidValue? Title { get; set; }
}
public class OrcidExternalIds
{
[JsonPropertyName("external-id")]
public List<OrcidExternalId>? ExternalId { get; set; }
}
public class OrcidExternalId
{
[JsonPropertyName("external-id-type")]
public string? ExternalIdType { get; set; }
[JsonPropertyName("external-id-value")]
public string? ExternalIdValue { get; set; }
}

View File

@@ -0,0 +1,505 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RealCV.Infrastructure.Data;
#nullable disable
namespace RealCV.Infrastructure.Data.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260125074319_AddTermsAcceptedAt")]
partial class AddTermsAcceptedAt
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.23")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex")
.HasFilter("[NormalizedName] IS NOT NULL");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("RoleId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderKey")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderDisplayName")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("RoleId")
.HasColumnType("uniqueidentifier");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
b.Property<string>("Value")
.HasColumnType("nvarchar(max)");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("RealCV.Domain.Entities.AuditLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("Details")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<Guid?>("EntityId")
.HasColumnType("uniqueidentifier");
b.Property<string>("EntityType")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("IpAddress")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("Action")
.HasDatabaseName("IX_AuditLogs_Action");
b.HasIndex("CreatedAt")
.HasDatabaseName("IX_AuditLogs_CreatedAt");
b.HasIndex("UserId")
.HasDatabaseName("IX_AuditLogs_UserId");
b.ToTable("AuditLogs");
});
modelBuilder.Entity("RealCV.Domain.Entities.CVCheck", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("BlobUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime2");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("ExtractedDataJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("OriginalFileName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("ProcessingStage")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("ReportJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<int?>("VeracityScore")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("Status")
.HasDatabaseName("IX_CVChecks_Status");
b.HasIndex("UserId")
.HasDatabaseName("IX_CVChecks_UserId");
b.ToTable("CVChecks");
});
modelBuilder.Entity("RealCV.Domain.Entities.CVFlag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("CVCheckId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<int>("ScoreImpact")
.HasColumnType("int");
b.Property<string>("Severity")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("CVCheckId")
.HasDatabaseName("IX_CVFlags_CVCheckId");
b.ToTable("CVFlags");
});
modelBuilder.Entity("RealCV.Domain.Entities.CompanyCache", b =>
{
b.Property<string>("CompanyNumber")
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("AccountsCategory")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CachedAt")
.HasColumnType("datetime2");
b.Property<string>("CompanyName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("CompanyType")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateOnly?>("DissolutionDate")
.HasColumnType("date");
b.Property<DateOnly?>("IncorporationDate")
.HasColumnType("date");
b.Property<string>("SicCodesJson")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("CompanyNumber");
b.ToTable("CompanyCache");
});
modelBuilder.Entity("RealCV.Infrastructure.Identity.ApplicationUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("AccessFailedCount")
.HasColumnType("int");
b.Property<int>("ChecksUsedThisMonth")
.HasColumnType("int");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("bit");
b.Property<bool>("LockoutEnabled")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("datetimeoffset");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("PasswordHash")
.HasColumnType("nvarchar(max)");
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("bit");
b.Property<string>("Plan")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("SecurityStamp")
.HasColumnType("nvarchar(max)");
b.Property<string>("StripeCustomerId")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<DateTime?>("TermsAcceptedAt")
.HasColumnType("datetime2");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("bit");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex")
.HasFilter("[NormalizedUserName] IS NOT NULL");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("RealCV.Domain.Entities.CVCheck", b =>
{
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany("CVChecks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("RealCV.Domain.Entities.CVFlag", b =>
{
b.HasOne("RealCV.Domain.Entities.CVCheck", "CVCheck")
.WithMany("Flags")
.HasForeignKey("CVCheckId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CVCheck");
});
modelBuilder.Entity("RealCV.Domain.Entities.CVCheck", b =>
{
b.Navigation("Flags");
});
modelBuilder.Entity("RealCV.Infrastructure.Identity.ApplicationUser", b =>
{
b.Navigation("CVChecks");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,29 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RealCV.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddTermsAcceptedAt : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "TermsAcceptedAt",
table: "AspNetUsers",
type: "datetime2",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "TermsAcceptedAt",
table: "AspNetUsers");
}
}
}

View File

@@ -393,6 +393,9 @@ namespace RealCV.Infrastructure.Data.Migrations
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("nvarchar(256)"); .HasColumnType("nvarchar(256)");
b.Property<DateTime?>("TermsAcceptedAt")
.HasColumnType("datetime2");
b.Property<bool>("TwoFactorEnabled") b.Property<bool>("TwoFactorEnabled")
.HasColumnType("bit"); .HasColumnType("bit");

View File

@@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
using Polly; using Polly;
using Polly.Extensions.Http; using Polly.Extensions.Http;
using RealCV.Application.Interfaces; using RealCV.Application.Interfaces;
using RealCV.Infrastructure.Clients;
using RealCV.Infrastructure.Configuration; using RealCV.Infrastructure.Configuration;
using RealCV.Infrastructure.Data; using RealCV.Infrastructure.Data;
using RealCV.Infrastructure.ExternalApis; using RealCV.Infrastructure.ExternalApis;
@@ -74,6 +75,13 @@ public static class DependencyInjection
services.Configure<LocalStorageSettings>( services.Configure<LocalStorageSettings>(
configuration.GetSection(LocalStorageSettings.SectionName)); configuration.GetSection(LocalStorageSettings.SectionName));
// Configure options for additional verification APIs
services.Configure<FcaOptions>(
configuration.GetSection("FcaRegister"));
services.Configure<GitHubOptions>(
configuration.GetSection("GitHub"));
// Configure HttpClient for CompaniesHouseClient with retry policy // Configure HttpClient for CompaniesHouseClient with retry policy
services.AddHttpClient<CompaniesHouseClient>((serviceProvider, client) => services.AddHttpClient<CompaniesHouseClient>((serviceProvider, client) =>
{ {
@@ -88,16 +96,34 @@ public static class DependencyInjection
}) })
.AddPolicyHandler(GetRetryPolicy()); .AddPolicyHandler(GetRetryPolicy());
// Configure HttpClient for FCA Register API
services.AddHttpClient<FcaRegisterClient>()
.AddPolicyHandler(GetRetryPolicy());
// Configure HttpClient for GitHub API
services.AddHttpClient<GitHubApiClient>()
.AddPolicyHandler(GetRetryPolicy());
// Configure HttpClient for ORCID API
services.AddHttpClient<OrcidClient>()
.AddPolicyHandler(GetRetryPolicy());
// Register services // Register services
services.AddScoped<ICVParserService, CVParserService>(); services.AddScoped<ICVParserService, CVParserService>();
services.AddScoped<ICompanyNameMatcherService, AICompanyNameMatcherService>(); services.AddScoped<ICompanyNameMatcherService, AICompanyNameMatcherService>();
services.AddScoped<ICompanyVerifierService, CompanyVerifierService>(); services.AddScoped<ICompanyVerifierService, CompanyVerifierService>();
services.AddScoped<IEducationVerifierService, EducationVerifierService>(); services.AddScoped<IEducationVerifierService, EducationVerifierService>();
services.AddScoped<ITimelineAnalyserService, TimelineAnalyserService>(); services.AddScoped<ITimelineAnalyserService, TimelineAnalyserService>();
services.AddScoped<ITextAnalysisService, TextAnalysisService>();
services.AddScoped<ICVCheckService, CVCheckService>(); services.AddScoped<ICVCheckService, CVCheckService>();
services.AddScoped<IUserContextService, UserContextService>(); services.AddScoped<IUserContextService, UserContextService>();
services.AddScoped<IAuditService, AuditService>(); services.AddScoped<IAuditService, AuditService>();
// Register additional verification services
services.AddScoped<IProfessionalVerifierService, ProfessionalVerifierService>();
services.AddScoped<IGitHubVerifierService, GitHubVerifierService>();
services.AddScoped<IAcademicVerifierService, AcademicVerifierService>();
// Register file storage - use local storage if configured, otherwise Azure // Register file storage - use local storage if configured, otherwise Azure
var useLocalStorage = configuration.GetValue<bool>("UseLocalStorage"); var useLocalStorage = configuration.GetValue<bool>("UseLocalStorage");
if (useLocalStorage) if (useLocalStorage)

View File

@@ -12,5 +12,7 @@ public class ApplicationUser : IdentityUser<Guid>
public int ChecksUsedThisMonth { get; set; } public int ChecksUsedThisMonth { get; set; }
public DateTime? TermsAcceptedAt { get; set; }
public ICollection<CVCheck> CVChecks { get; set; } = new List<CVCheck>(); public ICollection<CVCheck> CVChecks { get; set; } = new List<CVCheck>();
} }

View File

@@ -18,19 +18,19 @@ public sealed class ProcessCVCheckJob
private readonly ICompanyVerifierService _companyVerifierService; private readonly ICompanyVerifierService _companyVerifierService;
private readonly IEducationVerifierService _educationVerifierService; private readonly IEducationVerifierService _educationVerifierService;
private readonly ITimelineAnalyserService _timelineAnalyserService; private readonly ITimelineAnalyserService _timelineAnalyserService;
private readonly ITextAnalysisService _textAnalysisService;
private readonly IAuditService _auditService; private readonly IAuditService _auditService;
private readonly ILogger<ProcessCVCheckJob> _logger; private readonly ILogger<ProcessCVCheckJob> _logger;
private const int BaseScore = 100; private const int BaseScore = 100;
private const int UnverifiedCompanyPenalty = 10; private const int UnverifiedCompanyPenalty = 10;
private const int ImplausibleJobTitlePenalty = 15; private const int ImplausibleJobTitlePenalty = 15;
private const int CompanyVerificationFlagPenalty = 5; // Base penalty for company flags, actual from flag.ScoreImpact
private const int RapidProgressionPenalty = 10; private const int RapidProgressionPenalty = 10;
private const int EarlyCareerSeniorRolePenalty = 10; private const int EarlyCareerSeniorRolePenalty = 10;
private const int GapMonthPenalty = 1; private const int GapMonthPenalty = 1;
private const int MaxGapPenalty = 10; private const int MaxGapPenalty = 10;
private const int OverlapMonthPenalty = 2; private const int OverlapMonthPenalty = 2;
private const int DiplomaMillPenalty = 25; private const int UnaccreditedInstitutionPenalty = 25;
private const int SuspiciousInstitutionPenalty = 15; private const int SuspiciousInstitutionPenalty = 15;
private const int UnverifiedEducationPenalty = 5; private const int UnverifiedEducationPenalty = 5;
private const int EducationDatePenalty = 10; private const int EducationDatePenalty = 10;
@@ -42,6 +42,7 @@ public sealed class ProcessCVCheckJob
ICompanyVerifierService companyVerifierService, ICompanyVerifierService companyVerifierService,
IEducationVerifierService educationVerifierService, IEducationVerifierService educationVerifierService,
ITimelineAnalyserService timelineAnalyserService, ITimelineAnalyserService timelineAnalyserService,
ITextAnalysisService textAnalysisService,
IAuditService auditService, IAuditService auditService,
ILogger<ProcessCVCheckJob> logger) ILogger<ProcessCVCheckJob> logger)
{ {
@@ -51,6 +52,7 @@ public sealed class ProcessCVCheckJob
_companyVerifierService = companyVerifierService; _companyVerifierService = companyVerifierService;
_educationVerifierService = educationVerifierService; _educationVerifierService = educationVerifierService;
_timelineAnalyserService = timelineAnalyserService; _timelineAnalyserService = timelineAnalyserService;
_textAnalysisService = textAnalysisService;
_auditService = auditService; _auditService = auditService;
_logger = logger; _logger = logger;
} }
@@ -183,11 +185,11 @@ public sealed class ProcessCVCheckJob
cvData.Employment); cvData.Employment);
_logger.LogDebug( _logger.LogDebug(
"Education verification for check {CheckId}: {Count} entries verified ({Recognised} recognised, {DiplomaMill} diploma mills)", "Education verification for check {CheckId}: {Count} entries verified ({Recognised} recognised, {Unaccredited} unaccredited)",
cvCheckId, cvCheckId,
educationResults.Count, educationResults.Count,
educationResults.Count(e => e.IsVerified), educationResults.Count(e => e.IsVerified),
educationResults.Count(e => e.IsDiplomaMill)); educationResults.Count(e => e.IsUnaccredited));
// Step 7: Analyse timeline // Step 7: Analyse timeline
cvCheck.ProcessingStage = "Analysing Timeline"; cvCheck.ProcessingStage = "Analysing Timeline";
@@ -199,10 +201,23 @@ public sealed class ProcessCVCheckJob
"Timeline analysis for check {CheckId}: {GapCount} gaps, {OverlapCount} overlaps", "Timeline analysis for check {CheckId}: {GapCount} gaps, {OverlapCount} overlaps",
cvCheckId, timelineAnalysis.Gaps.Count, timelineAnalysis.Overlaps.Count); cvCheckId, timelineAnalysis.Gaps.Count, timelineAnalysis.Overlaps.Count);
// Step 7b: Analyse text for buzzwords, vague achievements, skills alignment, and metrics
cvCheck.ProcessingStage = "Analysing Content";
await _dbContext.SaveChangesAsync(cancellationToken);
var textAnalysis = _textAnalysisService.Analyse(cvData);
_logger.LogDebug(
"Text analysis for check {CheckId}: {BuzzwordCount} buzzwords, {VagueCount} vague statements, {MismatchCount} skill mismatches",
cvCheckId,
textAnalysis.BuzzwordAnalysis.TotalBuzzwords,
textAnalysis.AchievementAnalysis.VagueStatements,
textAnalysis.SkillsAlignment.Mismatches.Count);
// Step 8: Calculate veracity score // Step 8: Calculate veracity score
cvCheck.ProcessingStage = "Calculating Score"; cvCheck.ProcessingStage = "Calculating Score";
await _dbContext.SaveChangesAsync(cancellationToken); await _dbContext.SaveChangesAsync(cancellationToken);
var (score, flags) = CalculateVeracityScore(verificationResults, educationResults, timelineAnalysis, cvData); var (score, flags) = CalculateVeracityScore(verificationResults, educationResults, timelineAnalysis, textAnalysis, cvData);
_logger.LogDebug("Calculated veracity score for check {CheckId}: {Score}", cvCheckId, score); _logger.LogDebug("Calculated veracity score for check {CheckId}: {Score}", cvCheckId, score);
@@ -247,6 +262,7 @@ public sealed class ProcessCVCheckJob
EmploymentVerifications = verificationResults, EmploymentVerifications = verificationResults,
EducationVerifications = educationResults, EducationVerifications = educationResults,
TimelineAnalysis = timelineAnalysis, TimelineAnalysis = timelineAnalysis,
TextAnalysis = textAnalysis,
Flags = flags, Flags = flags,
GeneratedAt = DateTime.UtcNow GeneratedAt = DateTime.UtcNow
}; };
@@ -291,6 +307,7 @@ public sealed class ProcessCVCheckJob
List<CompanyVerificationResult> verifications, List<CompanyVerificationResult> verifications,
List<EducationVerificationResult> educationResults, List<EducationVerificationResult> educationResults,
TimelineAnalysisResult timeline, TimelineAnalysisResult timeline,
TextAnalysisResult textAnalysis,
CVData cvData) CVData cvData)
{ {
var score = BaseScore; var score = BaseScore;
@@ -389,23 +406,23 @@ public sealed class ProcessCVCheckJob
AddPLCExperienceFlag(verifications, flags); AddPLCExperienceFlag(verifications, flags);
AddVerifiedDirectorFlag(verifications, flags); AddVerifiedDirectorFlag(verifications, flags);
// Penalty for diploma mills (critical) // Penalty for unaccredited institutions (critical)
foreach (var edu in educationResults.Where(e => e.IsDiplomaMill)) foreach (var edu in educationResults.Where(e => e.IsUnaccredited))
{ {
score -= DiplomaMillPenalty; score -= UnaccreditedInstitutionPenalty;
flags.Add(new FlagResult flags.Add(new FlagResult
{ {
Category = FlagCategory.Education.ToString(), Category = FlagCategory.Education.ToString(),
Severity = FlagSeverity.Critical.ToString(), Severity = FlagSeverity.Critical.ToString(),
Title = "Diploma Mill Detected", Title = "Unaccredited Institution",
Description = $"'{edu.ClaimedInstitution}' is a known diploma mill. {edu.VerificationNotes}", Description = $"'{edu.ClaimedInstitution}' is not found in the register of recognised institutions. {edu.VerificationNotes}",
ScoreImpact = -DiplomaMillPenalty ScoreImpact = -UnaccreditedInstitutionPenalty
}); });
} }
// Penalty for suspicious institutions // Penalty for suspicious institutions
foreach (var edu in educationResults.Where(e => e.IsSuspicious && !e.IsDiplomaMill)) foreach (var edu in educationResults.Where(e => e.IsSuspicious && !e.IsUnaccredited))
{ {
score -= SuspiciousInstitutionPenalty; score -= SuspiciousInstitutionPenalty;
@@ -413,15 +430,15 @@ public sealed class ProcessCVCheckJob
{ {
Category = FlagCategory.Education.ToString(), Category = FlagCategory.Education.ToString(),
Severity = FlagSeverity.Warning.ToString(), Severity = FlagSeverity.Warning.ToString(),
Title = "Suspicious Institution", Title = "Institution Requires Verification",
Description = $"'{edu.ClaimedInstitution}' has suspicious characteristics. {edu.VerificationNotes}", Description = $"'{edu.ClaimedInstitution}' has characteristics that warrant additional verification. {edu.VerificationNotes}",
ScoreImpact = -SuspiciousInstitutionPenalty ScoreImpact = -SuspiciousInstitutionPenalty
}); });
} }
// Penalty for unverified education (not recognised, but not flagged as fake) // Penalty for unverified education (not recognised, but not flagged as unaccredited)
// Skip unknown/empty institutions as there's nothing to verify // Skip unknown/empty institutions as there's nothing to verify
foreach (var edu in educationResults.Where(e => !e.IsVerified && !e.IsDiplomaMill && !e.IsSuspicious && e.Status == "Unknown" foreach (var edu in educationResults.Where(e => !e.IsVerified && !e.IsUnaccredited && !e.IsSuspicious && e.Status == "Unknown"
&& !string.IsNullOrWhiteSpace(e.ClaimedInstitution) && !string.IsNullOrWhiteSpace(e.ClaimedInstitution)
&& !e.ClaimedInstitution.Equals("Unknown Institution", StringComparison.OrdinalIgnoreCase) && !e.ClaimedInstitution.Equals("Unknown Institution", StringComparison.OrdinalIgnoreCase)
&& !e.ClaimedInstitution.Equals("Unknown", StringComparison.OrdinalIgnoreCase))) && !e.ClaimedInstitution.Equals("Unknown", StringComparison.OrdinalIgnoreCase)))
@@ -485,6 +502,32 @@ public sealed class ProcessCVCheckJob
}); });
} }
// Process text analysis flags (buzzwords, vague achievements, skills alignment, metrics)
foreach (var textFlag in textAnalysis.Flags)
{
score += textFlag.ScoreImpact; // ScoreImpact is already negative
flags.Add(new FlagResult
{
Category = FlagCategory.Plausibility.ToString(),
Severity = textFlag.Severity,
Title = textFlag.Type switch
{
"ExcessiveBuzzwords" => "Excessive Buzzwords",
"HighBuzzwordCount" => "High Buzzword Count",
"VagueAchievements" => "Vague Achievements",
"LackOfQuantification" => "Lack of Quantification",
"SkillsJobMismatch" => "Skills/Job Mismatch",
"UnrealisticMetrics" => "Unrealistic Metrics",
"UnrealisticMetric" => "Unrealistic Metric",
"SuspiciouslyRoundNumbers" => "Suspiciously Round Numbers",
_ => textFlag.Type
},
Description = textFlag.Message,
ScoreImpact = textFlag.ScoreImpact
});
}
// Deduplicate flags based on Title + Description // Deduplicate flags based on Title + Description
var uniqueFlags = flags var uniqueFlags = flags
.GroupBy(f => (f.Title, f.Description)) .GroupBy(f => (f.Title, f.Description))

View File

@@ -0,0 +1,509 @@
using Microsoft.Extensions.Logging;
using RealCV.Application.Interfaces;
using RealCV.Application.Models;
using RealCV.Infrastructure.Clients;
using InterfaceOrcidSearchResult = RealCV.Application.Interfaces.OrcidSearchResult;
namespace RealCV.Infrastructure.Services;
public sealed class AcademicVerifierService : IAcademicVerifierService
{
private readonly OrcidClient _orcidClient;
private readonly ILogger<AcademicVerifierService> _logger;
public AcademicVerifierService(
OrcidClient orcidClient,
ILogger<AcademicVerifierService> logger)
{
_orcidClient = orcidClient;
_logger = logger;
}
public async Task<AcademicVerificationResult> VerifyByOrcidAsync(string orcidId)
{
try
{
_logger.LogInformation("Verifying ORCID: {OrcidId}", orcidId);
var record = await _orcidClient.GetRecordAsync(orcidId);
if (record == null)
{
return new AcademicVerificationResult
{
ClaimedName = orcidId,
IsVerified = false,
VerificationNotes = "ORCID record not found"
};
}
// Extract name
string? name = null;
if (record.Person?.Name != null)
{
var nameParts = new List<string>();
if (!string.IsNullOrEmpty(record.Person.Name.GivenNames?.Value))
nameParts.Add(record.Person.Name.GivenNames.Value);
if (!string.IsNullOrEmpty(record.Person.Name.FamilyName?.Value))
nameParts.Add(record.Person.Name.FamilyName.Value);
name = string.Join(" ", nameParts);
}
// Get detailed employment information
var affiliations = new List<AcademicAffiliation>();
var employments = await _orcidClient.GetEmploymentsAsync(orcidId);
if (employments?.AffiliationGroup != null)
{
affiliations.AddRange(ExtractAffiliations(employments.AffiliationGroup, "employment"));
}
// Get detailed education information
var educationList = new List<AcademicEducation>();
var educations = await _orcidClient.GetEducationsAsync(orcidId);
if (educations?.AffiliationGroup != null)
{
educationList.AddRange(ExtractEducations(educations.AffiliationGroup));
}
// Get works/publications
var publications = new List<Publication>();
var works = await _orcidClient.GetWorksAsync(orcidId);
if (works?.Group != null)
{
publications.AddRange(ExtractPublications(works.Group));
}
return new AcademicVerificationResult
{
ClaimedName = name ?? orcidId,
IsVerified = true,
OrcidId = orcidId,
MatchedName = name,
OrcidUrl = record.OrcidIdentifier?.Uri,
Affiliations = affiliations,
TotalPublications = publications.Count,
RecentPublications = publications.Take(10).ToList(),
Education = educationList,
VerificationNotes = BuildVerificationSummary(affiliations, publications.Count, educationList.Count)
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error verifying ORCID: {OrcidId}", orcidId);
return new AcademicVerificationResult
{
ClaimedName = orcidId,
IsVerified = false,
VerificationNotes = $"Error during verification: {ex.Message}"
};
}
}
public async Task<AcademicVerificationResult> VerifyByNameAsync(
string name,
string? affiliation = null)
{
try
{
_logger.LogInformation("Searching ORCID for: {Name} at {Affiliation}",
name, affiliation ?? "any affiliation");
var query = name;
if (!string.IsNullOrEmpty(affiliation))
{
query = $"{name} {affiliation}";
}
var searchResponse = await _orcidClient.SearchResearchersAsync(query);
if (searchResponse?.Result == null || searchResponse.Result.Count == 0)
{
return new AcademicVerificationResult
{
ClaimedName = name,
IsVerified = false,
VerificationNotes = "No matching ORCID records found"
};
}
// Get the first match's ORCID ID
var firstMatch = searchResponse.Result.First();
var orcidId = firstMatch.OrcidIdentifier?.Path;
if (string.IsNullOrEmpty(orcidId))
{
return new AcademicVerificationResult
{
ClaimedName = name,
IsVerified = false,
VerificationNotes = "Search returned results but no ORCID ID found"
};
}
// Get full details
var result = await VerifyByOrcidAsync(orcidId);
// Update claimed name to the search name
return result with { ClaimedName = name };
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching ORCID for: {Name}", name);
return new AcademicVerificationResult
{
ClaimedName = name,
IsVerified = false,
VerificationNotes = $"Error during search: {ex.Message}"
};
}
}
public async Task<List<InterfaceOrcidSearchResult>> SearchResearchersAsync(
string name,
string? affiliation = null)
{
try
{
var query = name;
if (!string.IsNullOrEmpty(affiliation))
{
query = $"{name} {affiliation}";
}
var searchResponse = await _orcidClient.SearchResearchersAsync(query, 0, 20);
if (searchResponse?.Result == null)
{
return [];
}
var results = new List<InterfaceOrcidSearchResult>();
foreach (var searchResult in searchResponse.Result.Take(10))
{
var orcidId = searchResult.OrcidIdentifier?.Path;
if (string.IsNullOrEmpty(orcidId))
continue;
var record = await _orcidClient.GetRecordAsync(orcidId);
if (record == null)
continue;
// Extract name
string? researcherName = null;
if (record.Person?.Name != null)
{
var nameParts = new List<string>();
if (!string.IsNullOrEmpty(record.Person.Name.GivenNames?.Value))
nameParts.Add(record.Person.Name.GivenNames.Value);
if (!string.IsNullOrEmpty(record.Person.Name.FamilyName?.Value))
nameParts.Add(record.Person.Name.FamilyName.Value);
researcherName = string.Join(" ", nameParts);
}
// Get affiliations
var affiliations = new List<string>();
if (record.ActivitiesSummary?.Employments?.AffiliationGroup != null)
{
affiliations = record.ActivitiesSummary.Employments.AffiliationGroup
.SelectMany(g => g.Summaries ?? [])
.Select(s => s.EmploymentSummary?.Organization?.Name)
.Where(n => !string.IsNullOrEmpty(n))
.Distinct()
.Take(5)
.ToList()!;
}
// Get publication count
var publicationCount = record.ActivitiesSummary?.Works?.Group?.Count ?? 0;
results.Add(new InterfaceOrcidSearchResult
{
OrcidId = orcidId,
Name = researcherName ?? "Unknown",
OrcidUrl = searchResult.OrcidIdentifier?.Uri,
Affiliations = affiliations,
PublicationCount = publicationCount
});
}
return results;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching ORCID for: {Name}", name);
return [];
}
}
public async Task<List<PublicationVerificationResult>> VerifyPublicationsAsync(
string orcidId,
List<string> claimedPublications)
{
var results = new List<PublicationVerificationResult>();
try
{
var works = await _orcidClient.GetWorksAsync(orcidId);
if (works?.Group == null)
{
return claimedPublications.Select(title => new PublicationVerificationResult
{
ClaimedTitle = title,
IsVerified = false,
Notes = "Could not retrieve ORCID publications"
}).ToList();
}
var orcidPublications = ExtractPublications(works.Group);
foreach (var claimedTitle in claimedPublications)
{
var match = FindBestPublicationMatch(claimedTitle, orcidPublications);
if (match != null)
{
results.Add(new PublicationVerificationResult
{
ClaimedTitle = claimedTitle,
IsVerified = true,
MatchedTitle = match.Title,
Doi = match.Doi,
Year = match.Year,
Notes = "Publication found in ORCID record"
});
}
else
{
results.Add(new PublicationVerificationResult
{
ClaimedTitle = claimedTitle,
IsVerified = false,
Notes = "Publication not found in ORCID record"
});
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error verifying publications for ORCID: {OrcidId}", orcidId);
return claimedPublications.Select(title => new PublicationVerificationResult
{
ClaimedTitle = title,
IsVerified = false,
Notes = $"Error during verification: {ex.Message}"
}).ToList();
}
return results;
}
private static List<AcademicAffiliation> ExtractAffiliations(
List<OrcidAffiliationGroup> groups,
string type)
{
var affiliations = new List<AcademicAffiliation>();
foreach (var group in groups)
{
if (group.Summaries == null)
continue;
foreach (var wrapper in group.Summaries)
{
var summary = type == "employment"
? wrapper.EmploymentSummary
: wrapper.EducationSummary;
if (summary?.Organization?.Name == null)
continue;
affiliations.Add(new AcademicAffiliation
{
Organization = summary.Organization.Name,
Department = summary.DepartmentName,
Role = summary.RoleTitle,
StartDate = ParseOrcidDate(summary.StartDate),
EndDate = ParseOrcidDate(summary.EndDate)
});
}
}
// Sort by start date descending (most recent first)
return affiliations
.OrderByDescending(a => a.StartDate)
.ToList();
}
private static List<AcademicEducation> ExtractEducations(List<OrcidAffiliationGroup> groups)
{
var educations = new List<AcademicEducation>();
foreach (var group in groups)
{
if (group.Summaries == null)
continue;
foreach (var wrapper in group.Summaries)
{
var summary = wrapper.EducationSummary;
if (summary?.Organization?.Name == null)
continue;
var startYear = summary.StartDate?.Year?.Value;
var endYear = summary.EndDate?.Year?.Value;
educations.Add(new AcademicEducation
{
Institution = summary.Organization.Name,
Degree = summary.RoleTitle,
Subject = summary.DepartmentName,
Year = !string.IsNullOrEmpty(endYear)
? int.TryParse(endYear, out var y) ? y : null
: !string.IsNullOrEmpty(startYear)
? int.TryParse(startYear, out var sy) ? sy : null
: null
});
}
}
return educations
.OrderByDescending(e => e.Year)
.ToList();
}
private static List<Publication> ExtractPublications(List<OrcidWorkGroup> groups)
{
var publications = new List<Publication>();
foreach (var group in groups)
{
if (group.WorkSummary == null || group.WorkSummary.Count == 0)
continue;
// Take the first work summary from each group (they're typically duplicates)
var work = group.WorkSummary.First();
var title = work.TitleObj?.Title?.Value ?? work.Title;
if (string.IsNullOrEmpty(title))
continue;
// Extract DOI if available
string? doi = null;
if (work.ExternalIds?.ExternalId != null)
{
var doiEntry = work.ExternalIds.ExternalId
.FirstOrDefault(e => e.ExternalIdType?.Equals("doi", StringComparison.OrdinalIgnoreCase) == true);
doi = doiEntry?.ExternalIdValue;
}
// Parse year
int? year = null;
if (!string.IsNullOrEmpty(work.PublicationDate?.Year?.Value) &&
int.TryParse(work.PublicationDate.Year.Value, out var y))
{
year = y;
}
publications.Add(new Publication
{
Title = title,
Journal = work.JournalTitle?.Value,
Year = year,
Doi = doi,
Type = work.Type
});
}
// Sort by publication year descending
return publications
.OrderByDescending(p => p.Year)
.ToList();
}
private static DateOnly? ParseOrcidDate(OrcidDate? date)
{
if (date?.Year?.Value == null)
return null;
if (!int.TryParse(date.Year.Value, out var year))
return null;
var month = 1;
if (!string.IsNullOrEmpty(date.Month?.Value) && int.TryParse(date.Month.Value, out var m))
month = m;
var day = 1;
if (!string.IsNullOrEmpty(date.Day?.Value) && int.TryParse(date.Day.Value, out var d))
day = d;
try
{
return new DateOnly(year, month, day);
}
catch
{
return new DateOnly(year, 1, 1);
}
}
private static Publication? FindBestPublicationMatch(string claimedTitle, List<Publication> publications)
{
var normalizedClaimed = NormalizeTitle(claimedTitle);
// First try exact match
var exactMatch = publications.FirstOrDefault(p =>
NormalizeTitle(p.Title).Equals(normalizedClaimed, StringComparison.OrdinalIgnoreCase));
if (exactMatch != null)
return exactMatch;
// Then try contains match
var containsMatch = publications.FirstOrDefault(p =>
NormalizeTitle(p.Title).Contains(normalizedClaimed, StringComparison.OrdinalIgnoreCase) ||
normalizedClaimed.Contains(NormalizeTitle(p.Title), StringComparison.OrdinalIgnoreCase));
return containsMatch;
}
private static string NormalizeTitle(string title)
{
return title
.ToLowerInvariant()
.Replace(":", " ")
.Replace("-", " ")
.Replace(" ", " ")
.Trim();
}
private static string BuildVerificationSummary(List<AcademicAffiliation> affiliations, int publicationCount, int educationCount)
{
var parts = new List<string>();
var currentAffiliation = affiliations.FirstOrDefault(a => !a.EndDate.HasValue);
if (currentAffiliation != null)
{
parts.Add($"Current: {currentAffiliation.Organization}");
}
if (publicationCount > 0)
{
parts.Add($"Publications: {publicationCount}");
}
if (affiliations.Count > 0)
{
parts.Add($"Positions: {affiliations.Count}");
}
if (educationCount > 0)
{
parts.Add($"Education: {educationCount}");
}
return parts.Count > 0 ? string.Join(" | ", parts) : "ORCID record verified";
}
}

View File

@@ -14,17 +14,17 @@ public sealed class EducationVerifierService : IEducationVerifierService
{ {
var institution = education.Institution; var institution = education.Institution;
// Check for diploma mill first (highest priority flag) // Check for unaccredited institution first (highest priority flag)
if (DiplomaMills.IsDiplomaMill(institution)) if (UnaccreditedInstitutions.IsUnaccredited(institution))
{ {
return new EducationVerificationResult return new EducationVerificationResult
{ {
ClaimedInstitution = institution, ClaimedInstitution = institution,
Status = "DiplomaMill", Status = "Unaccredited",
IsVerified = false, IsVerified = false,
IsDiplomaMill = true, IsUnaccredited = true,
IsSuspicious = true, IsSuspicious = true,
VerificationNotes = "Institution is on the diploma mill blacklist", VerificationNotes = "Institution not found in QAA/HESA register of recognised institutions",
ClaimedStartDate = education.StartDate, ClaimedStartDate = education.StartDate,
ClaimedEndDate = education.EndDate, ClaimedEndDate = education.EndDate,
DatesArePlausible = true, DatesArePlausible = true,
@@ -34,16 +34,16 @@ public sealed class EducationVerifierService : IEducationVerifierService
} }
// Check for suspicious patterns // Check for suspicious patterns
if (DiplomaMills.HasSuspiciousPattern(institution)) if (UnaccreditedInstitutions.HasSuspiciousPattern(institution))
{ {
return new EducationVerificationResult return new EducationVerificationResult
{ {
ClaimedInstitution = institution, ClaimedInstitution = institution,
Status = "Suspicious", Status = "Suspicious",
IsVerified = false, IsVerified = false,
IsDiplomaMill = false, IsUnaccredited = false,
IsSuspicious = true, IsSuspicious = true,
VerificationNotes = "Institution name contains suspicious patterns common in diploma mills", VerificationNotes = "Institution name contains patterns that may indicate unaccredited status",
ClaimedStartDate = education.StartDate, ClaimedStartDate = education.StartDate,
ClaimedEndDate = education.EndDate, ClaimedEndDate = education.EndDate,
DatesArePlausible = true, DatesArePlausible = true,
@@ -64,7 +64,7 @@ public sealed class EducationVerifierService : IEducationVerifierService
MatchedInstitution = officialName, MatchedInstitution = officialName,
Status = "Recognised", Status = "Recognised",
IsVerified = true, IsVerified = true,
IsDiplomaMill = false, IsUnaccredited = false,
IsSuspicious = false, IsSuspicious = false,
VerificationNotes = institution.Equals(officialName, StringComparison.OrdinalIgnoreCase) VerificationNotes = institution.Equals(officialName, StringComparison.OrdinalIgnoreCase)
? "Verified UK higher education institution" ? "Verified UK higher education institution"
@@ -78,6 +78,26 @@ public sealed class EducationVerifierService : IEducationVerifierService
}; };
} }
// Check if this looks like a UK university name but isn't recognised
// This catches fake institutions like "University of the Peak District"
if (LooksLikeUKUniversity(institution))
{
return new EducationVerificationResult
{
ClaimedInstitution = institution,
Status = "Suspicious",
IsVerified = false,
IsUnaccredited = false,
IsSuspicious = true,
VerificationNotes = "Institution uses UK university naming convention but is not found in the register of recognised UK institutions",
ClaimedStartDate = education.StartDate,
ClaimedEndDate = education.EndDate,
DatesArePlausible = true,
ClaimedQualification = education.Qualification,
ClaimedSubject = education.Subject
};
}
// Not in our database - could be international or unrecognised // Not in our database - could be international or unrecognised
var isUnknownInstitution = string.IsNullOrWhiteSpace(institution) || var isUnknownInstitution = string.IsNullOrWhiteSpace(institution) ||
institution.Equals("Unknown Institution", StringComparison.OrdinalIgnoreCase) || institution.Equals("Unknown Institution", StringComparison.OrdinalIgnoreCase) ||
@@ -88,7 +108,7 @@ public sealed class EducationVerifierService : IEducationVerifierService
ClaimedInstitution = institution, ClaimedInstitution = institution,
Status = "Unknown", Status = "Unknown",
IsVerified = false, IsVerified = false,
IsDiplomaMill = false, IsUnaccredited = false,
IsSuspicious = false, IsSuspicious = false,
VerificationNotes = isUnknownInstitution ? null : "Institution not found in UK recognised institutions database. May be an international institution.", VerificationNotes = isUnknownInstitution ? null : "Institution not found in UK recognised institutions database. May be an international institution.",
ClaimedStartDate = education.StartDate, ClaimedStartDate = education.StartDate,
@@ -99,6 +119,82 @@ public sealed class EducationVerifierService : IEducationVerifierService
}; };
} }
/// <summary>
/// Checks if an institution name follows UK university naming conventions.
/// If it does but isn't in the recognised list, it's likely a fake UK institution.
/// </summary>
private static bool LooksLikeUKUniversity(string? institution)
{
if (string.IsNullOrWhiteSpace(institution))
return false;
var lower = institution.ToLowerInvariant().Trim();
// Skip if explicitly marked as foreign/international
if (lower.Contains("foreign") || lower.Contains("international"))
return false;
// "University of the [X]" is a distinctly British naming pattern
// Examples: University of the West of England, University of the Highlands and Islands
// Fake examples: University of the Peak District, University of the Cotswolds
if (lower.StartsWith("university of the "))
return true;
// UK-specific naming patterns that are less common internationally
if (lower.Contains(" metropolitan university")) // Manchester Metropolitan University
return true;
if (lower.Contains(" brookes university")) // Oxford Brookes
return true;
if (lower.Contains(" hallam university")) // Sheffield Hallam
return true;
if (lower.Contains(" beckett university")) // Leeds Beckett
return true;
if (lower.Contains(" napier university")) // Edinburgh Napier
return true;
if (lower.Contains(" trent university")) // Nottingham Trent
return true;
if (lower.StartsWith("royal college of ")) // Royal College of Art, etc.
return true;
// Check for UK place names that don't have real universities
// These are well-known UK regions/places used by diploma mills
var fakeUkPatterns = new[]
{
"university of devonshire",
"university of cornwall", // No "University of Cornwall" - only Falmouth
"university of wiltshire",
"university of dorset",
"university of hampshire",
"university of norfolk",
"university of suffolk", // Note: There IS a University of Suffolk now
"university of berkshire",
"university of shropshire",
"university of herefordshire",
"university of rutland",
"university of cumbria", // This one exists - keep for now
"university of england",
"university of britain",
"university of the lake district",
"university of the cotswolds",
"university of the peak district",
"university of the dales",
"university of the moors",
"university of the fens",
"university of london south",
"university of london north",
"university of london east",
"university of london west",
};
foreach (var pattern in fakeUkPatterns)
{
if (lower.Contains(pattern))
return true;
}
return false;
}
public List<EducationVerificationResult> VerifyAll( public List<EducationVerificationResult> VerifyAll(
List<EducationEntry> education, List<EducationEntry> education,
List<EmploymentEntry>? employment = null) List<EmploymentEntry>? employment = null)

View File

@@ -0,0 +1,275 @@
using Microsoft.Extensions.Logging;
using RealCV.Application.Interfaces;
using RealCV.Application.Models;
using RealCV.Infrastructure.Clients;
namespace RealCV.Infrastructure.Services;
public sealed class GitHubVerifierService : IGitHubVerifierService
{
private readonly GitHubApiClient _gitHubClient;
private readonly ILogger<GitHubVerifierService> _logger;
// Map common skill names to GitHub languages
private static readonly Dictionary<string, string[]> SkillToLanguageMap = new(StringComparer.OrdinalIgnoreCase)
{
["JavaScript"] = ["JavaScript", "TypeScript"],
["TypeScript"] = ["TypeScript"],
["Python"] = ["Python"],
["Java"] = ["Java", "Kotlin"],
["C#"] = ["C#"],
[".NET"] = ["C#", "F#"],
["React"] = ["JavaScript", "TypeScript"],
["Angular"] = ["TypeScript", "JavaScript"],
["Vue"] = ["JavaScript", "TypeScript", "Vue"],
["Node.js"] = ["JavaScript", "TypeScript"],
["Go"] = ["Go"],
["Golang"] = ["Go"],
["Rust"] = ["Rust"],
["Ruby"] = ["Ruby"],
["PHP"] = ["PHP"],
["Swift"] = ["Swift"],
["Kotlin"] = ["Kotlin"],
["C++"] = ["C++", "C"],
["C"] = ["C"],
["Scala"] = ["Scala"],
["R"] = ["R"],
["SQL"] = ["PLSQL", "TSQL"],
["Shell"] = ["Shell", "Bash", "PowerShell"],
["DevOps"] = ["Shell", "Dockerfile", "HCL"],
["Docker"] = ["Dockerfile"],
["Terraform"] = ["HCL"],
["Mobile"] = ["Swift", "Kotlin", "Dart", "Java"],
["iOS"] = ["Swift", "Objective-C"],
["Android"] = ["Kotlin", "Java"],
["Flutter"] = ["Dart"],
["Machine Learning"] = ["Python", "Jupyter Notebook", "R"],
["Data Science"] = ["Python", "Jupyter Notebook", "R"],
};
public GitHubVerifierService(
GitHubApiClient gitHubClient,
ILogger<GitHubVerifierService> logger)
{
_gitHubClient = gitHubClient;
_logger = logger;
}
public async Task<GitHubVerificationResult> VerifyProfileAsync(string username)
{
try
{
_logger.LogInformation("Verifying GitHub profile: {Username}", username);
var user = await _gitHubClient.GetUserAsync(username);
if (user == null)
{
return new GitHubVerificationResult
{
ClaimedUsername = username,
IsVerified = false,
VerificationNotes = "GitHub profile not found"
};
}
// Get repositories for language analysis
var repos = await _gitHubClient.GetUserReposAsync(username);
// Analyze languages
var languageStats = new Dictionary<string, int>();
foreach (var repo in repos.Where(r => !r.Fork && !string.IsNullOrEmpty(r.Language)))
{
if (!languageStats.ContainsKey(repo.Language!))
languageStats[repo.Language!] = 0;
languageStats[repo.Language!]++;
}
// Calculate flags
var flags = new List<GitHubVerificationFlag>();
// Check account age
var accountAge = DateTime.UtcNow - user.CreatedAt;
if (accountAge.TotalDays < 90)
{
flags.Add(new GitHubVerificationFlag
{
Type = "NewAccount",
Severity = "Info",
Message = "Account created less than 90 days ago",
ScoreImpact = -5
});
}
// Check for empty profile
if (user.PublicRepos == 0)
{
flags.Add(new GitHubVerificationFlag
{
Type = "NoRepos",
Severity = "Warning",
Message = "No public repositories",
ScoreImpact = -10
});
}
return new GitHubVerificationResult
{
ClaimedUsername = username,
IsVerified = true,
ProfileName = user.Name,
ProfileUrl = user.HtmlUrl,
Bio = user.Bio,
Company = user.Company,
Location = user.Location,
AccountCreated = user.CreatedAt != default
? DateOnly.FromDateTime(user.CreatedAt)
: null,
PublicRepos = user.PublicRepos,
Followers = user.Followers,
Following = user.Following,
LanguageStats = languageStats,
VerificationNotes = BuildVerificationSummary(user, repos),
Flags = flags
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error verifying GitHub profile: {Username}", username);
return new GitHubVerificationResult
{
ClaimedUsername = username,
IsVerified = false,
VerificationNotes = $"Error during verification: {ex.Message}"
};
}
}
public async Task<GitHubVerificationResult> VerifySkillsAsync(
string username,
List<string> claimedSkills)
{
var result = await VerifyProfileAsync(username);
if (!result.IsVerified)
return result;
var skillVerifications = new List<SkillVerification>();
foreach (var skill in claimedSkills)
{
var verified = false;
var repoCount = 0;
// Check if the skill matches a known language directly
if (result.LanguageStats.TryGetValue(skill, out var count))
{
verified = true;
repoCount = count;
}
else if (SkillToLanguageMap.TryGetValue(skill, out var mappedLanguages))
{
// Check if any mapped language exists in the user's repos
foreach (var lang in mappedLanguages)
{
if (result.LanguageStats.TryGetValue(lang, out var langCount))
{
verified = true;
repoCount += langCount;
}
}
}
skillVerifications.Add(new SkillVerification
{
ClaimedSkill = skill,
IsVerified = verified,
RepoCount = repoCount,
Notes = verified
? $"Found in {repoCount} repositories"
: "No repositories found using this skill"
});
}
var verifiedCount = skillVerifications.Count(sv => sv.IsVerified);
var totalCount = skillVerifications.Count;
var percentage = totalCount > 0 ? (verifiedCount * 100) / totalCount : 0;
return result with
{
SkillVerifications = skillVerifications,
VerificationNotes = $"Skills verified: {verifiedCount}/{totalCount} ({percentage}%)"
};
}
public async Task<List<GitHubProfileSearchResult>> SearchProfilesAsync(string name)
{
try
{
var searchResponse = await _gitHubClient.SearchUsersAsync(name);
if (searchResponse?.Items == null)
{
return [];
}
var results = new List<GitHubProfileSearchResult>();
foreach (var item in searchResponse.Items.Take(10))
{
if (string.IsNullOrEmpty(item.Login))
continue;
// Get full profile details
var user = await _gitHubClient.GetUserAsync(item.Login);
results.Add(new GitHubProfileSearchResult
{
Username = item.Login,
Name = user?.Name,
AvatarUrl = item.AvatarUrl,
Bio = user?.Bio,
PublicRepos = user?.PublicRepos ?? 0,
Followers = user?.Followers ?? 0
});
}
return results;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching GitHub profiles: {Name}", name);
return [];
}
}
private static string BuildVerificationSummary(GitHubUser user, List<GitHubRepo> repos)
{
var parts = new List<string>
{
$"Account created: {user.CreatedAt:yyyy-MM-dd}",
$"Public repos: {user.PublicRepos}",
$"Followers: {user.Followers}"
};
var totalStars = repos.Sum(r => r.StargazersCount);
if (totalStars > 0)
{
parts.Add($"Total stars: {totalStars}");
}
var topLanguages = repos
.Where(r => !r.Fork && !string.IsNullOrEmpty(r.Language))
.GroupBy(r => r.Language)
.OrderByDescending(g => g.Count())
.Take(3)
.Select(g => g.Key);
if (topLanguages.Any())
{
parts.Add($"Top languages: {string.Join(", ", topLanguages)}");
}
return string.Join(" | ", parts);
}
}

View File

@@ -0,0 +1,219 @@
using Microsoft.Extensions.Logging;
using RealCV.Application.Interfaces;
using RealCV.Application.Models;
using RealCV.Infrastructure.Clients;
namespace RealCV.Infrastructure.Services;
public sealed class ProfessionalVerifierService : IProfessionalVerifierService
{
private readonly FcaRegisterClient _fcaClient;
private readonly ILogger<ProfessionalVerifierService> _logger;
public ProfessionalVerifierService(
FcaRegisterClient fcaClient,
ILogger<ProfessionalVerifierService> logger)
{
_fcaClient = fcaClient;
_logger = logger;
}
public async Task<ProfessionalVerificationResult> VerifyFcaRegistrationAsync(
string name,
string? firmName = null,
string? referenceNumber = null)
{
try
{
_logger.LogInformation("Verifying FCA registration for: {Name}", name);
// If we have a reference number, try to get directly
if (!string.IsNullOrEmpty(referenceNumber))
{
var details = await _fcaClient.GetIndividualAsync(referenceNumber);
if (details != null)
{
var isNameMatch = IsNameMatch(name, details.Name);
return new ProfessionalVerificationResult
{
ClaimedName = name,
ProfessionalBody = "FCA",
IsVerified = isNameMatch,
RegistrationNumber = details.IndividualReferenceNumber,
MatchedName = details.Name,
Status = details.Status,
RegistrationDate = ParseDate(details.EffectiveDate),
ControlledFunctions = details.ControlledFunctions?
.Where(cf => cf.Status?.Equals("Active", StringComparison.OrdinalIgnoreCase) == true)
.Select(cf => cf.ControlledFunction ?? "Unknown")
.ToList(),
VerificationNotes = isNameMatch
? $"FCA Individual Reference Number: {details.IndividualReferenceNumber}"
: "Reference number found but name does not match"
};
}
}
// Search by name
var searchResponse = await _fcaClient.SearchIndividualsAsync(name);
if (searchResponse?.Data == null || searchResponse.Data.Count == 0)
{
return new ProfessionalVerificationResult
{
ClaimedName = name,
ProfessionalBody = "FCA",
IsVerified = false,
VerificationNotes = "No matching FCA registered individuals found"
};
}
// Find best match
var matches = searchResponse.Data
.Where(i => IsNameMatch(name, i.Name))
.ToList();
if (matches.Count == 0)
{
return new ProfessionalVerificationResult
{
ClaimedName = name,
ProfessionalBody = "FCA",
IsVerified = false,
VerificationNotes = $"Found {searchResponse.Data.Count} results but no close name matches"
};
}
// If firm specified, try to match on that too
FcaIndividualSearchItem? bestMatch = null;
if (!string.IsNullOrEmpty(firmName))
{
bestMatch = matches.FirstOrDefault(m =>
m.CurrentEmployers?.Contains(firmName, StringComparison.OrdinalIgnoreCase) == true);
}
bestMatch ??= matches.First();
// Get detailed information
if (!string.IsNullOrEmpty(bestMatch.IndividualReferenceNumber))
{
var details = await _fcaClient.GetIndividualAsync(bestMatch.IndividualReferenceNumber);
if (details != null)
{
return new ProfessionalVerificationResult
{
ClaimedName = name,
ProfessionalBody = "FCA",
IsVerified = true,
RegistrationNumber = details.IndividualReferenceNumber,
MatchedName = details.Name,
Status = details.Status,
RegistrationDate = ParseDate(details.EffectiveDate),
CurrentEmployer = bestMatch.CurrentEmployers,
ControlledFunctions = details.ControlledFunctions?
.Where(cf => cf.Status?.Equals("Active", StringComparison.OrdinalIgnoreCase) == true)
.Select(cf => cf.ControlledFunction ?? "Unknown")
.ToList(),
VerificationNotes = $"FCA Individual Reference Number: {details.IndividualReferenceNumber}"
};
}
}
// Basic verification without details
return new ProfessionalVerificationResult
{
ClaimedName = name,
ProfessionalBody = "FCA",
IsVerified = true,
RegistrationNumber = bestMatch.IndividualReferenceNumber,
MatchedName = bestMatch.Name,
Status = bestMatch.Status,
CurrentEmployer = bestMatch.CurrentEmployers,
VerificationNotes = "Verified via FCA Register search"
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error verifying FCA registration for: {Name}", name);
return new ProfessionalVerificationResult
{
ClaimedName = name,
ProfessionalBody = "FCA",
IsVerified = false,
VerificationNotes = $"Error during verification: {ex.Message}"
};
}
}
public async Task<List<FcaIndividualSearchResult>> SearchFcaIndividualsAsync(string name)
{
try
{
var searchResponse = await _fcaClient.SearchIndividualsAsync(name);
if (searchResponse?.Data == null)
{
return [];
}
return searchResponse.Data
.Select(i => new FcaIndividualSearchResult
{
Name = i.Name ?? "Unknown",
IndividualReferenceNumber = i.IndividualReferenceNumber ?? "Unknown",
Status = i.Status,
CurrentFirms = i.CurrentEmployers?.Split(',')
.Select(f => f.Trim())
.Where(f => !string.IsNullOrEmpty(f))
.ToList()
})
.ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching FCA for: {Name}", name);
return [];
}
}
private static bool IsNameMatch(string searchName, string? foundName)
{
if (string.IsNullOrEmpty(foundName))
return false;
var searchNormalized = NormalizeName(searchName);
var foundNormalized = NormalizeName(foundName);
// Exact match
if (searchNormalized.Equals(foundNormalized, StringComparison.OrdinalIgnoreCase))
return true;
// Check if all parts of search name are in found name
var searchParts = searchNormalized.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var foundParts = foundNormalized.Split(' ', StringSplitOptions.RemoveEmptyEntries);
// All search parts must be found
return searchParts.All(sp =>
foundParts.Any(fp => fp.Equals(sp, StringComparison.OrdinalIgnoreCase)));
}
private static string NormalizeName(string name)
{
return name
.Replace(",", " ")
.Replace(".", " ")
.Replace("-", " ")
.Trim();
}
private static DateOnly? ParseDate(string? dateString)
{
if (string.IsNullOrEmpty(dateString))
return null;
if (DateOnly.TryParse(dateString, out var date))
return date;
return null;
}
}

View File

@@ -0,0 +1,593 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using RealCV.Application.Interfaces;
using RealCV.Application.Models;
namespace RealCV.Infrastructure.Services;
public sealed partial class TextAnalysisService : ITextAnalysisService
{
private readonly ILogger<TextAnalysisService> _logger;
public TextAnalysisService(ILogger<TextAnalysisService> logger)
{
_logger = logger;
}
public TextAnalysisResult Analyse(CVData cvData)
{
_logger.LogDebug("Starting text analysis for CV: {Name}", cvData.FullName);
var flags = new List<TextAnalysisFlag>();
// Run all analyses
var buzzwordAnalysis = AnalyseBuzzwords(cvData, flags);
var achievementAnalysis = AnalyseAchievements(cvData, flags);
var skillsAlignment = AnalyseSkillsAlignment(cvData, flags);
var metricsAnalysis = AnalyseMetrics(cvData, flags);
_logger.LogDebug(
"Text analysis complete: {BuzzwordCount} buzzwords, {VagueCount} vague statements, {MismatchCount} skill mismatches, {SuspiciousCount} suspicious metrics",
buzzwordAnalysis.TotalBuzzwords,
achievementAnalysis.VagueStatements,
skillsAlignment.Mismatches.Count,
metricsAnalysis.SuspiciousMetrics);
return new TextAnalysisResult
{
BuzzwordAnalysis = buzzwordAnalysis,
AchievementAnalysis = achievementAnalysis,
SkillsAlignment = skillsAlignment,
MetricsAnalysis = metricsAnalysis,
Flags = flags
};
}
#region Buzzword Detection
private static readonly HashSet<string> Buzzwords = new(StringComparer.OrdinalIgnoreCase)
{
// Overused personality descriptors
"results-driven", "detail-oriented", "team player", "self-starter",
"go-getter", "proactive", "dynamic", "passionate", "motivated",
"hardworking", "dedicated", "enthusiastic", "driven",
// Corporate jargon
"synergy", "leverage", "paradigm", "holistic", "innovative",
"disruptive", "scalable", "agile", "optimization", "strategic",
"streamline", "spearhead", "champion", "facilitate",
// Vague superlatives
"best-in-class", "world-class", "cutting-edge", "state-of-the-art",
"next-generation", "game-changer", "thought leader",
// Empty phrases
"think outside the box", "hit the ground running", "move the needle",
"low-hanging fruit", "value-add", "bandwidth", "circle back",
"deep dive", "pivot", "ecosystem"
};
private static readonly HashSet<string> BuzzwordPhrases = new(StringComparer.OrdinalIgnoreCase)
{
"results-driven professional",
"highly motivated individual",
"proven track record",
"strong work ethic",
"excellent interpersonal skills",
"ability to work independently",
"thrive under pressure",
"fast-paced environment",
"excellent communication skills",
"strategic thinker",
"problem solver",
"out of the box",
"above and beyond",
"value proposition"
};
private static BuzzwordAnalysis AnalyseBuzzwords(CVData cvData, List<TextAnalysisFlag> flags)
{
var allText = GetAllDescriptionText(cvData);
var textLower = allText.ToLower();
var wordCount = allText.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
var found = new List<string>();
// Check for phrases first
foreach (var phrase in BuzzwordPhrases)
{
if (textLower.Contains(phrase.ToLower()))
{
found.Add(phrase);
}
}
// Check individual buzzwords (avoiding duplicates from phrases)
foreach (var buzzword in Buzzwords)
{
if (textLower.Contains(buzzword.ToLower()) &&
!found.Any(f => f.Contains(buzzword, StringComparison.OrdinalIgnoreCase)))
{
found.Add(buzzword);
}
}
var density = wordCount > 0 ? found.Count / (wordCount / 100.0) : 0;
// Generate flags based on severity
if (found.Count >= 10)
{
flags.Add(new TextAnalysisFlag
{
Type = "ExcessiveBuzzwords",
Severity = "Warning",
Message = $"CV contains {found.Count} buzzwords/clichés - may indicate template or AI-generated content. Examples: {string.Join(", ", found.Take(5))}",
ScoreImpact = -10
});
}
else if (found.Count >= 6)
{
flags.Add(new TextAnalysisFlag
{
Type = "HighBuzzwordCount",
Severity = "Info",
Message = $"CV contains {found.Count} common buzzwords: {string.Join(", ", found.Take(4))}",
ScoreImpact = -5
});
}
return new BuzzwordAnalysis
{
TotalBuzzwords = found.Count,
BuzzwordsFound = found,
BuzzwordDensity = density
};
}
#endregion
#region Vague Achievement Detection
private static readonly string[] VaguePatterns =
[
"responsible for",
"worked on",
"helped with",
"assisted in",
"involved in",
"participated in",
"contributed to",
"various tasks",
"many projects",
"multiple initiatives",
"day-to-day",
"duties included",
"tasked with"
];
private static readonly string[] StrongActionVerbs =
[
"achieved", "increased", "reduced", "decreased", "improved",
"generated", "saved", "developed", "created", "launched",
"implemented", "negotiated", "secured", "designed", "built",
"led", "managed", "delivered", "transformed", "accelerated",
"streamlined", "consolidated", "eliminated", "maximized", "minimized"
];
private static AchievementAnalysis AnalyseAchievements(CVData cvData, List<TextAnalysisFlag> flags)
{
var totalStatements = 0;
var vagueStatements = 0;
var quantifiedStatements = 0;
var strongVerbStatements = 0;
var vagueExamples = new List<string>();
foreach (var job in cvData.Employment)
{
if (string.IsNullOrWhiteSpace(job.Description)) continue;
// Split into bullet points or sentences
var statements = job.Description
.Split(['\n', '•', '●', '■', '▪', '*', '-'], StringSplitOptions.RemoveEmptyEntries)
.Select(s => s.Trim())
.Where(s => s.Length > 10)
.ToList();
foreach (var statement in statements)
{
totalStatements++;
var statementLower = statement.ToLower();
// Check for quantification (numbers, percentages, currency)
if (HasQuantification().IsMatch(statement))
{
quantifiedStatements++;
}
// Check for strong action verbs at the start
if (StrongActionVerbs.Any(v => statementLower.StartsWith(v)))
{
strongVerbStatements++;
}
// Check for vague patterns
if (VaguePatterns.Any(p => statementLower.Contains(p)))
{
vagueStatements++;
if (vagueExamples.Count < 3)
{
var truncated = statement.Length > 60 ? statement[..57] + "..." : statement;
vagueExamples.Add(truncated);
}
}
}
}
// Generate flags
if (totalStatements > 0)
{
var vagueRatio = (double)vagueStatements / totalStatements;
var quantifiedRatio = (double)quantifiedStatements / totalStatements;
if (vagueRatio > 0.5 && totalStatements >= 5)
{
flags.Add(new TextAnalysisFlag
{
Type = "VagueAchievements",
Severity = "Warning",
Message = $"{vagueStatements} of {totalStatements} statements use vague language (e.g., 'responsible for', 'helped with'). Consider: \"{vagueExamples.FirstOrDefault()}\"",
ScoreImpact = -8
});
}
if (quantifiedRatio < 0.2 && totalStatements >= 5)
{
flags.Add(new TextAnalysisFlag
{
Type = "LackOfQuantification",
Severity = "Info",
Message = $"Only {quantifiedStatements} of {totalStatements} achievement statements include measurable results",
ScoreImpact = 0
});
}
}
return new AchievementAnalysis
{
TotalStatements = totalStatements,
VagueStatements = vagueStatements,
QuantifiedStatements = quantifiedStatements,
StrongActionVerbStatements = strongVerbStatements,
VagueExamples = vagueExamples
};
}
[GeneratedRegex(@"\d+%|\$[\d,]+|£[\d,]+|\d+\s*(million|thousand|k\b|m\b)|[0-9]+x\b", RegexOptions.IgnoreCase)]
private static partial Regex HasQuantification();
#endregion
#region Skills Alignment
private static readonly Dictionary<string, HashSet<string>> RoleSkillsMap = new(StringComparer.OrdinalIgnoreCase)
{
// Software/Tech roles
["software engineer"] = ["programming", "coding", "development", "software", "git", "testing", "code", "developer", "engineering"],
["software developer"] = ["programming", "coding", "development", "software", "git", "testing", "code", "developer"],
["web developer"] = ["html", "css", "javascript", "web", "frontend", "backend", "react", "angular", "vue", "node"],
["frontend developer"] = ["html", "css", "javascript", "react", "angular", "vue", "typescript", "ui", "ux"],
["backend developer"] = ["api", "database", "sql", "server", "node", "python", "java", "c#", ".net"],
["full stack"] = ["frontend", "backend", "javascript", "database", "api", "react", "node"],
["devops engineer"] = ["ci/cd", "docker", "kubernetes", "aws", "azure", "jenkins", "terraform", "infrastructure"],
["data scientist"] = ["python", "machine learning", "statistics", "data analysis", "sql", "r", "tensorflow", "pandas"],
["data analyst"] = ["sql", "excel", "data", "analysis", "tableau", "power bi", "statistics", "reporting"],
["data engineer"] = ["sql", "python", "etl", "data pipeline", "spark", "hadoop", "database", "aws", "azure"],
// Project/Product roles
["project manager"] = ["project management", "agile", "scrum", "stakeholder", "planning", "budget", "pmp", "prince2"],
["product manager"] = ["product", "roadmap", "stakeholder", "agile", "user research", "strategy", "backlog"],
["scrum master"] = ["scrum", "agile", "sprint", "kanban", "jira", "facilitation", "coaching"],
// Business roles
["business analyst"] = ["requirements", "analysis", "stakeholder", "documentation", "process", "sql", "jira"],
["marketing manager"] = ["marketing", "campaigns", "branding", "analytics", "seo", "content", "social media", "digital"],
["sales manager"] = ["sales", "revenue", "crm", "pipeline", "negotiation", "b2b", "b2c", "targets"],
// Finance roles
["accountant"] = ["accounting", "financial", "excel", "bookkeeping", "tax", "audit", "sage", "xero", "quickbooks"],
["financial analyst"] = ["financial", "modelling", "excel", "forecasting", "budgeting", "analysis", "reporting"],
// Design roles
["ux designer"] = ["ux", "user experience", "wireframe", "prototype", "figma", "sketch", "user research", "usability"],
["ui designer"] = ["ui", "visual design", "figma", "sketch", "adobe", "interface", "design systems"],
["graphic designer"] = ["photoshop", "illustrator", "indesign", "adobe", "design", "creative", "branding"],
// HR roles
["hr manager"] = ["hr", "human resources", "recruitment", "employee relations", "policy", "training", "performance"],
["recruiter"] = ["recruitment", "sourcing", "interviewing", "talent", "hiring", "ats", "linkedin"],
// Other common roles
["customer service"] = ["customer", "support", "service", "communication", "crm", "resolution"],
["operations manager"] = ["operations", "logistics", "process", "efficiency", "supply chain", "management"]
};
private static SkillsAlignmentAnalysis AnalyseSkillsAlignment(CVData cvData, List<TextAnalysisFlag> flags)
{
var mismatches = new List<SkillMismatch>();
var rolesChecked = 0;
var rolesWithMatchingSkills = 0;
// Normalize skills for matching
var skillsLower = cvData.Skills
.Select(s => s.ToLower().Trim())
.ToHashSet();
// Also extract skills mentioned in descriptions
var allText = GetAllDescriptionText(cvData).ToLower();
foreach (var job in cvData.Employment)
{
var titleLower = job.JobTitle.ToLower();
foreach (var (rolePattern, expectedSkills) in RoleSkillsMap)
{
if (!titleLower.Contains(rolePattern)) continue;
rolesChecked++;
// Find matching skills (in skills list OR mentioned in descriptions)
var matchingSkills = expectedSkills
.Where(expected =>
skillsLower.Any(s => s.Contains(expected)) ||
allText.Contains(expected))
.ToList();
if (matchingSkills.Count >= 2)
{
rolesWithMatchingSkills++;
}
else
{
mismatches.Add(new SkillMismatch
{
JobTitle = job.JobTitle,
CompanyName = job.CompanyName,
ExpectedSkills = expectedSkills.Take(5).ToList(),
MatchingSkills = matchingSkills
});
}
break; // Only match first role pattern
}
}
// Generate flags for significant mismatches
if (mismatches.Count >= 2)
{
var examples = mismatches.Take(2)
.Select(m => $"'{m.JobTitle}' lacks typical skills")
.ToList();
flags.Add(new TextAnalysisFlag
{
Type = "SkillsJobMismatch",
Severity = "Warning",
Message = $"{mismatches.Count} roles have few matching skills listed. {string.Join("; ", examples)}. Expected skills like: {string.Join(", ", mismatches.First().ExpectedSkills.Take(3))}",
ScoreImpact = -8
});
}
else if (mismatches.Count == 1)
{
var m = mismatches.First();
flags.Add(new TextAnalysisFlag
{
Type = "SkillsJobMismatch",
Severity = "Info",
Message = $"Role '{m.JobTitle}' at {m.CompanyName} has limited matching skills. Expected: {string.Join(", ", m.ExpectedSkills.Take(4))}",
ScoreImpact = -3
});
}
return new SkillsAlignmentAnalysis
{
TotalRolesChecked = rolesChecked,
RolesWithMatchingSkills = rolesWithMatchingSkills,
Mismatches = mismatches
};
}
#endregion
#region Unrealistic Metrics Detection
private static MetricsAnalysis AnalyseMetrics(CVData cvData, List<TextAnalysisFlag> flags)
{
var allText = GetAllDescriptionText(cvData);
var suspiciousMetrics = new List<SuspiciousMetric>();
var totalMetrics = 0;
var plausibleMetrics = 0;
// Revenue/growth increase patterns
var revenuePattern = RevenueIncreasePattern();
foreach (Match match in revenuePattern.Matches(allText))
{
totalMetrics++;
var value = double.Parse(match.Groups[1].Value);
if (value > 300)
{
suspiciousMetrics.Add(new SuspiciousMetric
{
ClaimText = match.Value,
Value = value,
Reason = $"{value}% increase is exceptionally high - requires verification"
});
}
else if (value > 200)
{
suspiciousMetrics.Add(new SuspiciousMetric
{
ClaimText = match.Value,
Value = value,
Reason = $"{value}% is unusually high for most contexts"
});
}
else
{
plausibleMetrics++;
}
}
// Cost reduction patterns
var costPattern = CostReductionPattern();
foreach (Match match in costPattern.Matches(allText))
{
totalMetrics++;
var value = double.Parse(match.Groups[1].Value);
if (value > 70)
{
suspiciousMetrics.Add(new SuspiciousMetric
{
ClaimText = match.Value,
Value = value,
Reason = $"{value}% cost reduction is extremely rare"
});
}
else
{
plausibleMetrics++;
}
}
// Efficiency/productivity improvements
var efficiencyPattern = EfficiencyPattern();
foreach (Match match in efficiencyPattern.Matches(allText))
{
totalMetrics++;
var value = double.Parse(match.Groups[1].Value);
if (value > 500)
{
suspiciousMetrics.Add(new SuspiciousMetric
{
ClaimText = match.Value,
Value = value,
Reason = $"{value}% efficiency gain is implausible"
});
}
else if (value > 200)
{
suspiciousMetrics.Add(new SuspiciousMetric
{
ClaimText = match.Value,
Value = value,
Reason = $"{value}% improvement is unusually high"
});
}
else
{
plausibleMetrics++;
}
}
// Check for suspiciously round numbers
var (roundCount, roundRatio) = AnalyseRoundNumbers(allText);
// Generate flags
if (suspiciousMetrics.Count >= 2)
{
flags.Add(new TextAnalysisFlag
{
Type = "UnrealisticMetrics",
Severity = "Warning",
Message = $"{suspiciousMetrics.Count} achievement metrics appear exaggerated. Example: \"{suspiciousMetrics.First().ClaimText}\" - {suspiciousMetrics.First().Reason}",
ScoreImpact = -10
});
}
else if (suspiciousMetrics.Count == 1)
{
flags.Add(new TextAnalysisFlag
{
Type = "UnrealisticMetric",
Severity = "Info",
Message = $"Metric may be exaggerated: \"{suspiciousMetrics.First().ClaimText}\" - {suspiciousMetrics.First().Reason}",
ScoreImpact = -3
});
}
if (roundRatio > 0.8 && totalMetrics >= 4)
{
flags.Add(new TextAnalysisFlag
{
Type = "SuspiciouslyRoundNumbers",
Severity = "Info",
Message = $"{roundCount} of {totalMetrics} metrics are round numbers (ending in 0 or 5) - real data is rarely this clean",
ScoreImpact = -3
});
}
return new MetricsAnalysis
{
TotalMetricsClaimed = totalMetrics,
PlausibleMetrics = plausibleMetrics,
SuspiciousMetrics = suspiciousMetrics.Count,
RoundNumberCount = roundCount,
RoundNumberRatio = roundRatio,
SuspiciousMetricsList = suspiciousMetrics
};
}
[GeneratedRegex(@"(?:increased|grew|boosted|raised|improved)\s+(?:\w+\s+){0,3}(?:by\s+)?(\d+)%", RegexOptions.IgnoreCase)]
private static partial Regex RevenueIncreasePattern();
[GeneratedRegex(@"(?:reduced|cut|decreased|saved|lowered)\s+(?:\w+\s+){0,3}(?:by\s+)?(\d+)%", RegexOptions.IgnoreCase)]
private static partial Regex CostReductionPattern();
[GeneratedRegex(@"(\d+)%\s+(?:faster|quicker|more efficient|improvement|productivity|increase)", RegexOptions.IgnoreCase)]
private static partial Regex EfficiencyPattern();
private static (int RoundCount, double RoundRatio) AnalyseRoundNumbers(string text)
{
var numberPattern = NumberPattern();
var matches = numberPattern.Matches(text);
var total = 0;
var roundCount = 0;
foreach (Match match in matches)
{
var numStr = match.Groups[1].Success ? match.Groups[1].Value : match.Groups[2].Value;
numStr = numStr.Replace(",", "");
if (int.TryParse(numStr, out var num) && num >= 10)
{
total++;
if (num % 10 == 0 || num % 5 == 0)
{
roundCount++;
}
}
}
return (roundCount, total > 0 ? (double)roundCount / total : 0);
}
[GeneratedRegex(@"(\d+)%|(?:\$|£)([\d,]+)")]
private static partial Regex NumberPattern();
#endregion
#region Helpers
private static string GetAllDescriptionText(CVData cvData)
{
var descriptions = cvData.Employment
.Where(e => !string.IsNullOrWhiteSpace(e.Description))
.Select(e => e.Description!);
return string.Join(" ", descriptions);
}
#endregion
}

View File

@@ -74,7 +74,11 @@
<footer class="text-light py-4 mt-auto" style="background-color: var(--realcv-footer-bg);"> <footer class="text-light py-4 mt-auto" style="background-color: var(--realcv-footer-bg);">
<div class="container text-center"> <div class="container text-center">
<p class="mb-0">&copy; @DateTime.Now.Year RealCV. All rights reserved.</p> <p class="mb-2">&copy; @DateTime.Now.Year RealCV. All rights reserved.</p>
<p class="mb-0 small">
<a href="/privacy" class="text-light text-decoration-none me-3">Privacy Policy</a>
<a href="/terms" class="text-light text-decoration-none">Terms of Service</a>
</p>
</div> </div>
</footer> </footer>
</div> </div>

View File

@@ -14,12 +14,6 @@
<!-- Left side - Form --> <!-- Left side - Form -->
<div class="auth-form-side"> <div class="auth-form-side">
<div class="auth-form-wrapper"> <div class="auth-form-wrapper">
<div class="text-center mb-4">
<a href="/">
<img src="images/RealCV_Logo.png" alt="RealCV" class="auth-logo" />
</a>
</div>
<h1 class="auth-title">Welcome back</h1> <h1 class="auth-title">Welcome back</h1>
<p class="auth-subtitle">Sign in to continue verifying CVs</p> <p class="auth-subtitle">Sign in to continue verifying CVs</p>

View File

@@ -16,12 +16,6 @@
<!-- Left side - Form --> <!-- Left side - Form -->
<div class="auth-form-side"> <div class="auth-form-side">
<div class="auth-form-wrapper"> <div class="auth-form-wrapper">
<div class="text-center mb-4">
<a href="/">
<img src="images/RealCV_Logo.png" alt="RealCV" class="auth-logo" />
</a>
</div>
<h1 class="auth-title">Create account</h1> <h1 class="auth-title">Create account</h1>
<p class="auth-subtitle">Start verifying UK-based CVs in minutes</p> <p class="auth-subtitle">Start verifying UK-based CVs in minutes</p>
@@ -77,6 +71,17 @@
<ValidationMessage For="() => _model.ConfirmPassword" class="text-danger small mt-1" /> <ValidationMessage For="() => _model.ConfirmPassword" class="text-danger small mt-1" />
</div> </div>
<div class="mb-4">
<div class="form-check">
<InputCheckbox id="agreeToTerms" class="form-check-input" @bind-Value="_model.AgreeToTerms" />
<label class="form-check-label" for="agreeToTerms">
I agree to the <a href="/terms" target="_blank" class="text-decoration-none">Terms of Service</a>
and have read the <a href="/privacy" target="_blank" class="text-decoration-none">Privacy Policy</a>
</label>
</div>
<ValidationMessage For="() => _model.AgreeToTerms" class="text-danger small mt-1" />
</div>
<div class="d-grid mb-4"> <div class="d-grid mb-4">
<button type="submit" class="btn btn-primary btn-lg" disabled="@_isLoading"> <button type="submit" class="btn btn-primary btn-lg" disabled="@_isLoading">
@if (_isLoading) @if (_isLoading)
@@ -95,13 +100,6 @@
</div> </div>
</EditForm> </EditForm>
<p class="text-center text-muted small mb-4">
By creating an account, you agree to our
<a href="#" class="text-decoration-none">Terms of Service</a>
and
<a href="#" class="text-decoration-none">Privacy Policy</a>
</p>
<div class="auth-divider"> <div class="auth-divider">
<span>Already have an account?</span> <span>Already have an account?</span>
</div> </div>
@@ -189,7 +187,8 @@
UserName = _model.Email, UserName = _model.Email,
Email = _model.Email, Email = _model.Email,
Plan = Domain.Enums.UserPlan.Free, Plan = Domain.Enums.UserPlan.Free,
ChecksUsedThisMonth = 0 ChecksUsedThisMonth = 0,
TermsAcceptedAt = DateTime.UtcNow
}; };
var result = await UserManager.CreateAsync(user, _model.Password); var result = await UserManager.CreateAsync(user, _model.Password);
@@ -228,5 +227,8 @@
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Please confirm your password")] [System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Please confirm your password")]
[System.ComponentModel.DataAnnotations.Compare(nameof(Password), ErrorMessage = "Passwords do not match")] [System.ComponentModel.DataAnnotations.Compare(nameof(Password), ErrorMessage = "Passwords do not match")]
public string ConfirmPassword { get; set; } = string.Empty; public string ConfirmPassword { get; set; } = string.Empty;
[System.ComponentModel.DataAnnotations.Range(typeof(bool), "true", "true", ErrorMessage = "You must agree to the Terms of Service")]
public bool AgreeToTerms { get; set; }
} }
} }

View File

@@ -13,8 +13,8 @@
<PageTitle>Dashboard - RealCV</PageTitle> <PageTitle>Dashboard - RealCV</PageTitle>
<div class="container py-5"> <div class="container py-3">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-3">
<div> <div>
<h1 class="fw-bold mb-1">Dashboard</h1> <h1 class="fw-bold mb-1">Dashboard</h1>
<p class="text-muted mb-0">View and manage your CV verification checks</p> <p class="text-muted mb-0">View and manage your CV verification checks</p>
@@ -93,13 +93,13 @@
else else
{ {
<!-- Stats Cards --> <!-- Stats Cards -->
<div class="row mb-4 g-4"> <div class="row mb-3 g-3">
<div class="col-md-4"> <div class="col-md-4">
<div class="card border-0 shadow-sm stat-card h-100"> <div class="card border-0 shadow-sm stat-card h-100">
<div class="card-body p-4"> <div class="card-body p-3">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="stat-icon stat-icon-primary me-3"> <div class="stat-icon stat-icon-primary me-3">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="currentColor" viewBox="0 0 16 16">
<path d="M10.854 7.854a.5.5 0 0 0-.708-.708L7.5 9.793 6.354 8.646a.5.5 0 1 0-.708.708l1.5 1.5a.5.5 0 0 0 .708 0l3-3z"/> <path d="M10.854 7.854a.5.5 0 0 0-.708-.708L7.5 9.793 6.354 8.646a.5.5 0 1 0-.708.708l1.5 1.5a.5.5 0 0 0 .708 0l3-3z"/>
<path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2zM9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5v2z"/> <path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2zM9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5v2z"/>
</svg> </svg>
@@ -114,10 +114,10 @@
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<div class="card border-0 shadow-sm stat-card h-100"> <div class="card border-0 shadow-sm stat-card h-100">
<div class="card-body p-4"> <div class="card-body p-3">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="stat-icon stat-icon-success me-3"> <div class="stat-icon stat-icon-success me-3">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/> <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
<path d="M10.97 4.97a.235.235 0 0 0-.02.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05z"/> <path d="M10.97 4.97a.235.235 0 0 0-.02.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05z"/>
</svg> </svg>
@@ -132,10 +132,10 @@
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<div class="card border-0 shadow-sm stat-card h-100"> <div class="card border-0 shadow-sm stat-card h-100">
<div class="card-body p-4"> <div class="card-body p-3">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="stat-icon stat-icon-warning me-3"> <div class="stat-icon stat-icon-warning me-3">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="currentColor" viewBox="0 0 16 16">
<path d="M2.5 15a.5.5 0 1 1 0-1h1v-1a4.5 4.5 0 0 1 2.557-4.06c.29-.139.443-.377.443-.59v-.7c0-.213-.154-.451-.443-.59A4.5 4.5 0 0 1 3.5 3V2h-1a.5.5 0 0 1 0-1h11a.5.5 0 0 1 0 1h-1v1a4.5 4.5 0 0 1-2.557 4.06c-.29.139-.443.377-.443.59v.7c0 .213.154.451.443.59A4.5 4.5 0 0 1 12.5 13v1h1a.5.5 0 0 1 0 1h-11zm2-13v1c0 .537.12 1.045.337 1.5h6.326c.216-.455.337-.963.337-1.5V2h-7zm3 6.35c0 .701-.478 1.236-1.011 1.492A3.5 3.5 0 0 0 4.5 13s.866-1.299 3-1.48V8.35zm1 0v3.17c2.134.181 3 1.48 3 1.48a3.5 3.5 0 0 0-1.989-3.158C8.978 9.586 8.5 9.052 8.5 8.351z"/> <path d="M2.5 15a.5.5 0 1 1 0-1h1v-1a4.5 4.5 0 0 1 2.557-4.06c.29-.139.443-.377.443-.59v-.7c0-.213-.154-.451-.443-.59A4.5 4.5 0 0 1 3.5 3V2h-1a.5.5 0 0 1 0-1h11a.5.5 0 0 1 0 1h-1v1a4.5 4.5 0 0 1-2.557 4.06c-.29.139-.443.377-.443.59v.7c0 .213.154.451.443.59A4.5 4.5 0 0 1 12.5 13v1h1a.5.5 0 0 1 0 1h-11zm2-13v1c0 .537.12 1.045.337 1.5h6.326c.216-.455.337-.963.337-1.5V2h-7zm3 6.35c0 .701-.478 1.236-1.011 1.492A3.5 3.5 0 0 0 4.5 13s.866-1.299 3-1.48V8.35zm1 0v3.17c2.134.181 3 1.48 3 1.48a3.5 3.5 0 0 0-1.989-3.158C8.978 9.586 8.5 9.052 8.5 8.351z"/>
</svg> </svg>
</div> </div>
@@ -151,7 +151,7 @@
<!-- Checks List --> <!-- Checks List -->
<div class="card border-0 shadow-sm"> <div class="card border-0 shadow-sm">
<div class="card-header py-3 border-bottom" style="background-color: var(--realcv-bg-surface);"> <div class="card-header py-2 border-bottom" style="background-color: var(--realcv-bg-surface);">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-3"> <div class="d-flex align-items-center gap-3">
<h5 class="mb-0 fw-bold">Recent CV Checks</h5> <h5 class="mb-0 fw-bold">Recent CV Checks</h5>
@@ -178,17 +178,17 @@
<table class="table table-hover align-middle mb-0"> <table class="table table-hover align-middle mb-0">
<thead> <thead>
<tr style="background-color: var(--realcv-bg-muted);"> <tr style="background-color: var(--realcv-bg-muted);">
<th class="border-0 ps-3 py-3" style="width: 40px;"> <th class="border-0 ps-3 py-2" style="width: 40px;">
<input type="checkbox" class="form-check-input" <input type="checkbox" class="form-check-input"
checked="@IsAllSelected()" checked="@IsAllSelected()"
@onchange="ToggleSelectAll" @onchange="ToggleSelectAll"
title="Select all" /> title="Select all" />
</th> </th>
<th class="border-0 py-3 text-uppercase small fw-semibold text-muted" style="letter-spacing: 0.05em;">Candidate</th> <th class="border-0 py-2 text-uppercase small fw-semibold text-muted" style="letter-spacing: 0.05em;">Candidate</th>
<th class="border-0 py-3 text-uppercase small fw-semibold text-muted" style="letter-spacing: 0.05em;">Uploaded</th> <th class="border-0 py-2 text-uppercase small fw-semibold text-muted" style="letter-spacing: 0.05em;">Uploaded</th>
<th class="border-0 py-3 text-uppercase small fw-semibold text-muted text-center" style="letter-spacing: 0.05em;">Status</th> <th class="border-0 py-2 text-uppercase small fw-semibold text-muted text-center" style="letter-spacing: 0.05em;">Status</th>
<th class="border-0 py-3 text-uppercase small fw-semibold text-muted text-center" style="letter-spacing: 0.05em;">Score</th> <th class="border-0 py-2 text-uppercase small fw-semibold text-muted text-center" style="letter-spacing: 0.05em;">Score</th>
<th class="border-0 py-3 pe-4 text-uppercase small fw-semibold text-muted text-end" style="letter-spacing: 0.05em;">Actions</th> <th class="border-0 py-2 pe-4 text-uppercase small fw-semibold text-muted text-end" style="letter-spacing: 0.05em;">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -196,15 +196,15 @@
{ {
<tr class="@(check.Status == "Completed" ? "cursor-pointer" : "") @(_selectedIds.Contains(check.Id) ? "table-active" : "")" <tr class="@(check.Status == "Completed" ? "cursor-pointer" : "") @(_selectedIds.Contains(check.Id) ? "table-active" : "")"
@onclick="() => ViewReport(check)"> @onclick="() => ViewReport(check)">
<td class="ps-3 py-3" @onclick:stopPropagation="true"> <td class="ps-3 py-2" @onclick:stopPropagation="true">
<input type="checkbox" class="form-check-input" <input type="checkbox" class="form-check-input"
checked="@_selectedIds.Contains(check.Id)" checked="@_selectedIds.Contains(check.Id)"
@onchange="() => ToggleSelection(check.Id)" /> @onchange="() => ToggleSelection(check.Id)" />
</td> </td>
<td class="py-3"> <td class="py-2">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="file-icon-wrapper me-3"> <div class="file-icon-wrapper me-3">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-file-earmark-person text-primary" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-file-earmark-person text-primary" viewBox="0 0 16 16">
<path d="M11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/> <path d="M11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
<path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2zM9.5 3A1.5 1.5 0 0 0 11 4.5h2v9.255S12 12 8 12s-5 1.755-5 1.755V2a1 1 0 0 1 1-1h5.5v2z"/> <path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2zM9.5 3A1.5 1.5 0 0 0 11 4.5h2v9.255S12 12 8 12s-5 1.755-5 1.755V2a1 1 0 0 1 1-1h5.5v2z"/>
</svg> </svg>
@@ -215,17 +215,17 @@
</div> </div>
</div> </div>
</td> </td>
<td class="py-3"> <td class="py-2">
<div> <div>
<p class="mb-0 small">@check.CreatedAt.ToString("dd MMM yyyy")</p> <p class="mb-0 small">@check.CreatedAt.ToString("dd MMM yyyy")</p>
<small class="text-muted">@check.CreatedAt.ToString("HH:mm")</small> <small class="text-muted">@check.CreatedAt.ToString("HH:mm")</small>
</div> </div>
</td> </td>
<td class="py-3 text-center"> <td class="py-2 text-center">
@switch (check.Status) @switch (check.Status)
{ {
case "Completed": case "Completed":
<span class="badge rounded-pill bg-success-subtle text-success px-3 py-2"> <span class="badge rounded-pill bg-success-subtle text-success px-2 py-1">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="bi bi-check-circle-fill me-1" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="bi bi-check-circle-fill me-1" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/> <path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
</svg> </svg>
@@ -233,13 +233,13 @@
</span> </span>
break; break;
case "Processing": case "Processing":
<span class="badge rounded-pill bg-primary-subtle text-primary px-3 py-2"> <span class="badge rounded-pill bg-primary-subtle text-primary px-2 py-1">
<span class="spinner-border spinner-border-sm me-1" role="status" style="width: 0.75rem; height: 0.75rem;"></span> <span class="spinner-border spinner-border-sm me-1" role="status" style="width: 0.75rem; height: 0.75rem;"></span>
@(check.ProcessingStage ?? "Processing") @(check.ProcessingStage ?? "Processing")
</span> </span>
break; break;
case "Pending": case "Pending":
<span class="badge rounded-pill bg-secondary-subtle text-secondary px-3 py-2"> <span class="badge rounded-pill bg-secondary-subtle text-secondary px-2 py-1">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="bi bi-clock me-1" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="bi bi-clock me-1" viewBox="0 0 16 16">
<path d="M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71V3.5z"/> <path d="M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71V3.5z"/>
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z"/> <path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z"/>
@@ -248,7 +248,7 @@
</span> </span>
break; break;
case "Failed": case "Failed":
<span class="badge rounded-pill bg-danger-subtle text-danger px-3 py-2"> <span class="badge rounded-pill bg-danger-subtle text-danger px-2 py-1">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="bi bi-x-circle-fill me-1" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" class="bi bi-x-circle-fill me-1" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/> <path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
</svg> </svg>
@@ -256,11 +256,11 @@
</span> </span>
break; break;
default: default:
<span class="badge rounded-pill bg-secondary-subtle text-secondary px-3 py-2">@check.Status</span> <span class="badge rounded-pill bg-secondary-subtle text-secondary px-2 py-1">@check.Status</span>
break; break;
} }
</td> </td>
<td class="py-3 text-center"> <td class="py-2 text-center">
@if (check.VeracityScore.HasValue) @if (check.VeracityScore.HasValue)
{ {
<div class="score-ring-container" title="Veracity Score: @check.VeracityScore%"> <div class="score-ring-container" title="Veracity Score: @check.VeracityScore%">
@@ -278,7 +278,7 @@
<span class="text-muted">--</span> <span class="text-muted">--</span>
} }
</td> </td>
<td class="py-3 pe-4 text-end"> <td class="py-2 pe-4 text-end">
<div class="d-flex justify-content-end align-items-center gap-2"> <div class="d-flex justify-content-end align-items-center gap-2">
@if (check.Status == "Completed") @if (check.Status == "Completed")
{ {
@@ -399,8 +399,8 @@
} }
.file-icon-wrapper { .file-icon-wrapper {
width: 44px; width: 36px;
height: 44px; height: 36px;
background: linear-gradient(135deg, #e8f1fa 0%, #d4e4f4 100%); background: linear-gradient(135deg, #e8f1fa 0%, #d4e4f4 100%);
border-radius: 10px; border-radius: 10px;
display: flex; display: flex;
@@ -410,8 +410,8 @@
.score-ring-container { .score-ring-container {
position: relative; position: relative;
width: 52px; width: 42px;
height: 52px; height: 42px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;

View File

@@ -0,0 +1,144 @@
@page "/privacy"
<PageTitle>Privacy Policy - RealCV</PageTitle>
<div class="container py-5">
<div class="row">
<div class="col-lg-10 mx-auto">
<h1 class="fw-bold mb-4">Privacy Policy</h1>
<p class="text-muted mb-5">Last updated: @DateTime.UtcNow.ToString("dd MMMM yyyy")</p>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-4 p-lg-5">
<h2 class="h4 fw-bold mb-3">1. Who We Are</h2>
<p>
RealCV is a CV verification service that helps employers verify the employment history
and educational qualifications claimed by job candidates. We are the data controller
for the personal data processed through our service.
</p>
<h2 class="h4 fw-bold mb-3 mt-5">2. Information We Process</h2>
<p>We process the following types of personal data:</p>
<ul>
<li><strong>CV Content:</strong> Employment history, educational qualifications, names,
job titles, dates of employment/education, and other information contained in uploaded CVs</li>
<li><strong>Verification Results:</strong> Information obtained from Companies House,
educational institution registers, and other public sources</li>
<li><strong>User Account Data:</strong> Email addresses and authentication information
for registered users of our platform</li>
</ul>
<h2 class="h4 fw-bold mb-3 mt-5">3. How We Use Your Information</h2>
<p>We use personal data for the following purposes:</p>
<ul>
<li>Verifying employment claims against Companies House records</li>
<li>Checking educational institution accreditation status</li>
<li>Identifying timeline inconsistencies in CVs</li>
<li>Generating verification reports for our clients</li>
<li>Improving our verification algorithms and service quality</li>
</ul>
<h2 class="h4 fw-bold mb-3 mt-5">4. Legal Basis for Processing</h2>
<p>
We process personal data on the basis of <strong>legitimate interests</strong> (GDPR Article 6(1)(f)).
Our legitimate interests include:
</p>
<ul>
<li>Helping employers make informed hiring decisions</li>
<li>Preventing CV fraud and misrepresentation</li>
<li>Maintaining trust and integrity in the hiring process</li>
</ul>
<p>
We have conducted a Legitimate Interests Assessment to ensure that our processing is
necessary and does not override the rights and freedoms of data subjects.
</p>
<h2 class="h4 fw-bold mb-3 mt-5">5. Information About Candidates</h2>
<p>
When an employer uploads a candidate's CV for verification, the candidate becomes a
data subject under UK GDPR. In accordance with Article 14, we recognise that candidates
have rights regarding their personal data, including:
</p>
<ul>
<li><strong>Right to be informed:</strong> Candidates should be informed by the employer
that their CV may be subject to verification checks</li>
<li><strong>Right of access:</strong> Candidates may request a copy of any personal data
we hold about them</li>
<li><strong>Right to rectification:</strong> Candidates may request correction of inaccurate
personal data</li>
<li><strong>Right to erasure:</strong> Candidates may request deletion of their personal data
in certain circumstances</li>
<li><strong>Right to object:</strong> Candidates may object to processing based on legitimate
interests</li>
</ul>
<p>
Employers using our service are required to ensure appropriate notice is given to candidates
about the verification process in accordance with their legal obligations.
</p>
<h2 class="h4 fw-bold mb-3 mt-5">6. Data Retention</h2>
<p>We retain personal data as follows:</p>
<ul>
<li><strong>Verification reports:</strong> Retained for 2 years from the date of generation,
unless earlier deletion is requested</li>
<li><strong>Uploaded CV files:</strong> Automatically deleted 30 days after processing</li>
<li><strong>User account data:</strong> Retained until account deletion is requested</li>
</ul>
<h2 class="h4 fw-bold mb-3 mt-5">7. Data Security</h2>
<p>
We implement appropriate technical and organisational measures to protect personal data,
including:
</p>
<ul>
<li>Encryption of data in transit and at rest</li>
<li>Secure authentication and access controls</li>
<li>Regular security assessments</li>
<li>Staff training on data protection</li>
</ul>
<h2 class="h4 fw-bold mb-3 mt-5">8. Third-Party Services</h2>
<p>We may share personal data with the following categories of recipients:</p>
<ul>
<li><strong>Cloud infrastructure providers:</strong> For hosting and data storage</li>
<li><strong>AI service providers:</strong> For CV parsing and analysis</li>
<li><strong>Public registries:</strong> Companies House and educational institution registers
(publicly available data)</li>
</ul>
<h2 class="h4 fw-bold mb-3 mt-5">9. International Transfers</h2>
<p>
Personal data may be transferred to and processed in countries outside the UK. Where such
transfers occur, we ensure appropriate safeguards are in place in accordance with UK GDPR
requirements.
</p>
<h2 class="h4 fw-bold mb-3 mt-5">10. Your Rights</h2>
<p>You have the right to:</p>
<ul>
<li>Request access to your personal data</li>
<li>Request correction of inaccurate data</li>
<li>Request deletion of your data</li>
<li>Object to processing based on legitimate interests</li>
<li>Request restriction of processing</li>
<li>Lodge a complaint with the Information Commissioner's Office (ICO)</li>
</ul>
<h2 class="h4 fw-bold mb-3 mt-5">11. Contact Us</h2>
<p>
For any questions about this privacy policy or to exercise your data protection rights,
please contact us at: <strong>privacy@realcv.co.uk</strong>
</p>
<p>
You also have the right to lodge a complaint with the Information Commissioner's Office:
<a href="https://ico.org.uk" target="_blank" rel="noopener noreferrer">ico.org.uk</a>
</p>
</div>
</div>
<div class="text-center mt-4">
<a href="/" class="btn btn-outline-primary">Back to Home</a>
</div>
</div>
</div>
</div>

View File

@@ -131,7 +131,7 @@
<div class="col-12"> <div class="col-12">
<div class="card border-0 shadow-sm overflow-hidden"> <div class="card border-0 shadow-sm overflow-hidden">
<div class="score-header"> <div class="score-header">
<div class="row align-items-center py-4 px-3"> <div class="row align-items-center py-2 px-3">
<div class="col-md-4 text-center"> <div class="col-md-4 text-center">
<div class="score-roundel @GetScoreColorClass(_report!.OverallScore)"> <div class="score-roundel @GetScoreColorClass(_report!.OverallScore)">
<svg class="score-ring" viewBox="0 0 120 120"> <svg class="score-ring" viewBox="0 0 120 120">
@@ -153,10 +153,10 @@
<span class="score-max">/100</span> <span class="score-max">/100</span>
</div> </div>
</div> </div>
<div class="mt-2 text-white truecv-score-label">RealCV Score</div> <div class="mt-1 text-white truecv-score-label">RealCV Score</div>
</div> </div>
<div class="col-md-8"> <div class="col-md-8">
<div class="row g-4 text-center text-md-start"> <div class="row g-2 text-center text-md-start">
<div class="col-4"> <div class="col-4">
<div class="stat-item"> <div class="stat-item">
<div class="stat-icon"> <div class="stat-icon">
@@ -165,7 +165,7 @@
<path d="M2 1a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V1Zm11 0H3v14h3v-2.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5V15h3V1Z"/> <path d="M2 1a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V1Zm11 0H3v14h3v-2.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 .5.5V15h3V1Z"/>
</svg> </svg>
</div> </div>
<h3 class="mb-0 fw-bold text-white">@_report.EmploymentVerifications.Count</h3> <h5 class="mb-0 fw-bold text-white">@_report.EmploymentVerifications.Count</h5>
<small class="stat-label">Employers Checked</small> <small class="stat-label">Employers Checked</small>
</div> </div>
</div> </div>
@@ -177,7 +177,7 @@
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/> <path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/>
</svg> </svg>
</div> </div>
<h3 class="mb-0 fw-bold text-white">@_report.TimelineAnalysis.TotalGapMonths</h3> <h5 class="mb-0 fw-bold text-white">@_report.TimelineAnalysis.TotalGapMonths</h5>
<small class="stat-label">Gap Months</small> <small class="stat-label">Gap Months</small>
</div> </div>
</div> </div>
@@ -188,7 +188,7 @@
<path d="M14.778.085A.5.5 0 0 1 15 .5V8a.5.5 0 0 1-.314.464L14.5 8l.186.464-.003.001-.006.003-.023.009a12.435 12.435 0 0 1-.397.15c-.264.095-.631.223-1.047.35-.816.252-1.879.523-2.71.523-.847 0-1.548-.28-2.158-.525l-.028-.01C7.68 8.71 7.14 8.5 6.5 8.5c-.7 0-1.638.23-2.437.477A19.626 19.626 0 0 0 3 9.342V15.5a.5.5 0 0 1-1 0V.5a.5.5 0 0 1 1 0v.282c.226-.079.496-.17.79-.26C4.606.272 5.67 0 6.5 0c.84 0 1.524.277 2.121.519l.043.018C9.286.788 9.828 1 10.5 1c.7 0 1.638-.23 2.437-.477a19.587 19.587 0 0 0 1.349-.476l.019-.007.004-.002h.001"/> <path d="M14.778.085A.5.5 0 0 1 15 .5V8a.5.5 0 0 1-.314.464L14.5 8l.186.464-.003.001-.006.003-.023.009a12.435 12.435 0 0 1-.397.15c-.264.095-.631.223-1.047.35-.816.252-1.879.523-2.71.523-.847 0-1.548-.28-2.158-.525l-.028-.01C7.68 8.71 7.14 8.5 6.5 8.5c-.7 0-1.638.23-2.437.477A19.626 19.626 0 0 0 3 9.342V15.5a.5.5 0 0 1-1 0V.5a.5.5 0 0 1 1 0v.282c.226-.079.496-.17.79-.26C4.606.272 5.67 0 6.5 0c.84 0 1.524.277 2.121.519l.043.018C9.286.788 9.828 1 10.5 1c.7 0 1.638-.23 2.437-.477a19.587 19.587 0 0 0 1.349-.476l.019-.007.004-.002h.001"/>
</svg> </svg>
</div> </div>
<h3 class="mb-0 fw-bold text-white">@_report.Flags.Count</h3> <h5 class="mb-0 fw-bold text-white">@_report.Flags.Count</h5>
<small class="stat-label">Flags Raised</small> <small class="stat-label">Flags Raised</small>
</div> </div>
</div> </div>
@@ -470,6 +470,40 @@
</div> </div>
</div> </div>
} }
<!-- Legal Disclaimer -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h6 class="text-muted mb-3">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-info-circle me-2" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
<path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/>
</svg>
Important Information
</h6>
<div class="small text-muted">
<p class="mb-2">
<strong>This report is for informational purposes only.</strong> The verification results are based on
publicly available data from Companies House and other official sources. This analysis should be
used as one input among many in your hiring decision-making process.
</p>
<p class="mb-2">
<strong>Limitations:</strong> This automated verification cannot confirm whether a specific individual
actually worked at a verified company, only that the company exists and was active during the claimed
employment period. Education verification is based on institutional recognition status only.
</p>
<p class="mb-2">
<strong>Not a substitute for thorough background checks:</strong> We recommend supplementing this
report with direct reference checks, qualification verification with issuing institutions, and
other appropriate due diligence measures.
</p>
<p class="mb-0">
<strong>Candidate rights:</strong> Data subjects have the right to request access to, correction of,
or deletion of their personal data. For enquiries, please contact us via our website.
</p>
</div>
</div>
</div>
} }
</div> </div>
@@ -496,12 +530,12 @@
/* Score Roundel */ /* Score Roundel */
.score-roundel { .score-roundel {
position: relative; position: relative;
width: 140px; width: 100px;
height: 140px; height: 100px;
margin: 0 auto; margin: 0 auto;
background: rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.15);
border-radius: 50%; border-radius: 50%;
padding: 8px; padding: 6px;
} }
.score-roundel .score-ring { .score-roundel .score-ring {
@@ -555,14 +589,14 @@
} }
.score-roundel .score-value { .score-roundel .score-value {
font-size: 2.5rem; font-size: 1.75rem;
font-weight: 700; font-weight: 700;
line-height: 1; line-height: 1;
color: white; color: white;
} }
.score-roundel .score-max { .score-roundel .score-max {
font-size: 1rem; font-size: 0.75rem;
opacity: 0.85; opacity: 0.85;
color: white; color: white;
} }

View File

@@ -0,0 +1,162 @@
@page "/terms"
<PageTitle>Terms of Service - RealCV</PageTitle>
<div class="container py-5">
<div class="row">
<div class="col-lg-10 mx-auto">
<h1 class="fw-bold mb-4">Terms of Service</h1>
<p class="text-muted mb-5">Last updated: @DateTime.UtcNow.ToString("dd MMMM yyyy")</p>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body p-4 p-lg-5">
<h2 class="h4 fw-bold mb-3">1. Introduction</h2>
<p>
These Terms of Service ("Terms") govern your use of RealCV's CV verification services.
By accessing or using our service, you agree to be bound by these Terms. If you do not
agree to these Terms, please do not use our service.
</p>
<h2 class="h4 fw-bold mb-3 mt-5">2. Service Description</h2>
<p>
RealCV provides automated CV verification services that cross-reference information
in CVs against publicly available data sources, including Companies House records
and educational institution registers. Our service generates verification reports
to assist employers in their hiring decisions.
</p>
<h2 class="h4 fw-bold mb-3 mt-5">3. User Responsibilities</h2>
<p>By using our service, you agree to:</p>
<ul>
<li>Use the service only for lawful purposes related to recruitment and employment</li>
<li>Obtain appropriate consent or provide appropriate notice to candidates before
uploading their CVs for verification, as required by applicable data protection laws</li>
<li>Ensure that the use of verification reports complies with equality and employment laws</li>
<li>Not use verification results as the sole basis for making adverse hiring decisions</li>
<li>Maintain the confidentiality of verification reports and not share them beyond
those with a legitimate need to know</li>
<li>Not attempt to circumvent, disable, or otherwise interfere with security features
of the service</li>
</ul>
<h2 class="h4 fw-bold mb-3 mt-5">4. Candidate Notice Requirements</h2>
<div class="alert alert-info mb-4">
<strong>Important:</strong> Under UK GDPR Article 14, candidates have the right to be
informed when their personal data is being processed. You must ensure candidates are
appropriately notified about the verification process.
</div>
<p>As a user of RealCV, you agree to:</p>
<ul>
<li>Inform candidates that their CV may be subject to verification checks as part
of your recruitment process</li>
<li>Include reference to background/verification checks in your privacy notice
or candidate communications</li>
<li>Provide candidates with access to verification results upon reasonable request</li>
<li>Allow candidates the opportunity to dispute or provide context for any flags
raised in the verification report</li>
</ul>
<h2 class="h4 fw-bold mb-3 mt-5">5. Limitations of Service</h2>
<p>You acknowledge and agree that:</p>
<ul>
<li><strong>Informational purposes only:</strong> Verification reports are provided
for informational purposes and should be used as one input among many in your
hiring decision-making process</li>
<li><strong>Not proof of employment:</strong> Company verification confirms only that
a company existed and was active during the claimed period, not that the specific
individual was employed there</li>
<li><strong>Educational verification limits:</strong> Educational institution checks
verify accreditation status only, not individual qualification attainment</li>
<li><strong>Data accuracy:</strong> We rely on third-party data sources which may
contain errors or be out of date</li>
<li><strong>Automated analysis:</strong> Our service uses automated analysis which
may produce false positives or miss certain issues</li>
</ul>
<h2 class="h4 fw-bold mb-3 mt-5">6. Candidate Dispute Process</h2>
<p>
If a candidate disputes any information in a verification report, you agree to:
</p>
<ul>
<li>Give the candidate an opportunity to explain any discrepancies before making
adverse hiring decisions</li>
<li>Notify RealCV of any significant inaccuracies in our verification data so we
can investigate and correct our records</li>
<li>Not rely solely on verification flags without allowing the candidate to respond</li>
</ul>
<h2 class="h4 fw-bold mb-3 mt-5">7. Prohibited Uses</h2>
<p>You may not use our service to:</p>
<ul>
<li>Discriminate against candidates on the basis of protected characteristics</li>
<li>Make automated decisions about candidates without human oversight</li>
<li>Conduct surveillance or monitoring beyond legitimate recruitment purposes</li>
<li>Resell or redistribute verification reports without authorisation</li>
<li>Process CVs for purposes other than genuine recruitment activities</li>
</ul>
<h2 class="h4 fw-bold mb-3 mt-5">8. Disclaimer of Warranties</h2>
<p>
THE SERVICE IS PROVIDED "AS IS" AND "AS AVAILABLE" WITHOUT WARRANTIES OF ANY KIND,
EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO IMPLIED WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.
</p>
<p>
We do not warrant that the service will be uninterrupted, secure, or error-free,
or that the results obtained from the service will be accurate or reliable.
</p>
<h2 class="h4 fw-bold mb-3 mt-5">9. Limitation of Liability</h2>
<p>
TO THE MAXIMUM EXTENT PERMITTED BY LAW, REALCV SHALL NOT BE LIABLE FOR ANY INDIRECT,
INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, INCLUDING BUT NOT LIMITED TO
LOSS OF PROFITS, DATA, USE, GOODWILL, OR OTHER INTANGIBLE LOSSES, RESULTING FROM:
</p>
<ul>
<li>Your use of or inability to use the service</li>
<li>Any hiring decisions made based on verification reports</li>
<li>Any claims brought against you by candidates or third parties</li>
<li>Errors or inaccuracies in verification data</li>
</ul>
<p>
You agree to indemnify and hold harmless RealCV from any claims arising from your
use of the service or your violation of these Terms.
</p>
<h2 class="h4 fw-bold mb-3 mt-5">10. Intellectual Property</h2>
<p>
The service, including all content, features, and functionality, is owned by RealCV
and is protected by copyright, trademark, and other intellectual property laws.
You may not copy, modify, distribute, sell, or lease any part of our service without
our prior written consent.
</p>
<h2 class="h4 fw-bold mb-3 mt-5">11. Modifications to Terms</h2>
<p>
We reserve the right to modify these Terms at any time. We will notify you of any
material changes by posting the new Terms on this page with an updated revision date.
Your continued use of the service after such changes constitutes acceptance of the
modified Terms.
</p>
<h2 class="h4 fw-bold mb-3 mt-5">12. Governing Law</h2>
<p>
These Terms shall be governed by and construed in accordance with the laws of
England and Wales. Any disputes arising under or in connection with these Terms
shall be subject to the exclusive jurisdiction of the courts of England and Wales.
</p>
<h2 class="h4 fw-bold mb-3 mt-5">13. Contact Information</h2>
<p>
For any questions about these Terms, please contact us at:
<strong>legal@realcv.co.uk</strong>
</p>
</div>
</div>
<div class="text-center mt-4">
<a href="/" class="btn btn-outline-primary">Back to Home</a>
</div>
</div>
</div>
</div>

View File

@@ -18,6 +18,13 @@
"ConnectionString": "", "ConnectionString": "",
"ContainerName": "cv-uploads" "ContainerName": "cv-uploads"
}, },
"FcaRegister": {
"ApiKey": "9ae1aee51e5c717a1135775501c89075",
"Email": "peter.foster@ukdataservices.co.uk"
},
"GitHub": {
"PersonalAccessToken": ""
},
"Serilog": { "Serilog": {
"MinimumLevel": { "MinimumLevel": {
"Default": "Information", "Default": "Information",

View File

@@ -49,9 +49,9 @@
/* Surface colors */ /* Surface colors */
--realcv-bg-page: #F8FAFC; --realcv-bg-page: #F8FAFC;
--realcv-bg-surface: #FFFFFF; --realcv-bg-surface: #FAFAF9;
--realcv-bg-muted: #F1F5F9; --realcv-bg-muted: #F1F5F9;
--realcv-bg-elevated: #FFFFFF; --realcv-bg-elevated: #FEFEFE;
/* Footer & header */ /* Footer & header */
--realcv-header-bg: #FFFFFF; --realcv-header-bg: #FFFFFF;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -20,6 +20,7 @@ public sealed class ProcessCVCheckJobTests : IDisposable
private readonly Mock<ICompanyVerifierService> _companyVerifierServiceMock; private readonly Mock<ICompanyVerifierService> _companyVerifierServiceMock;
private readonly Mock<IEducationVerifierService> _educationVerifierServiceMock; private readonly Mock<IEducationVerifierService> _educationVerifierServiceMock;
private readonly Mock<ITimelineAnalyserService> _timelineAnalyserServiceMock; private readonly Mock<ITimelineAnalyserService> _timelineAnalyserServiceMock;
private readonly Mock<ITextAnalysisService> _textAnalysisServiceMock;
private readonly Mock<IAuditService> _auditServiceMock; private readonly Mock<IAuditService> _auditServiceMock;
private readonly Mock<ILogger<ProcessCVCheckJob>> _loggerMock; private readonly Mock<ILogger<ProcessCVCheckJob>> _loggerMock;
private readonly ProcessCVCheckJob _sut; private readonly ProcessCVCheckJob _sut;
@@ -41,6 +42,7 @@ public sealed class ProcessCVCheckJobTests : IDisposable
_companyVerifierServiceMock = new Mock<ICompanyVerifierService>(); _companyVerifierServiceMock = new Mock<ICompanyVerifierService>();
_educationVerifierServiceMock = new Mock<IEducationVerifierService>(); _educationVerifierServiceMock = new Mock<IEducationVerifierService>();
_timelineAnalyserServiceMock = new Mock<ITimelineAnalyserService>(); _timelineAnalyserServiceMock = new Mock<ITimelineAnalyserService>();
_textAnalysisServiceMock = new Mock<ITextAnalysisService>();
_auditServiceMock = new Mock<IAuditService>(); _auditServiceMock = new Mock<IAuditService>();
_loggerMock = new Mock<ILogger<ProcessCVCheckJob>>(); _loggerMock = new Mock<ILogger<ProcessCVCheckJob>>();
@@ -51,6 +53,7 @@ public sealed class ProcessCVCheckJobTests : IDisposable
_companyVerifierServiceMock.Object, _companyVerifierServiceMock.Object,
_educationVerifierServiceMock.Object, _educationVerifierServiceMock.Object,
_timelineAnalyserServiceMock.Object, _timelineAnalyserServiceMock.Object,
_textAnalysisServiceMock.Object,
_auditServiceMock.Object, _auditServiceMock.Object,
_loggerMock.Object); _loggerMock.Object);
} }
@@ -1073,6 +1076,10 @@ public sealed class ProcessCVCheckJobTests : IDisposable
_timelineAnalyserServiceMock _timelineAnalyserServiceMock
.Setup(x => x.Analyse(It.IsAny<List<EmploymentEntry>>())) .Setup(x => x.Analyse(It.IsAny<List<EmploymentEntry>>()))
.Returns(timelineResult); .Returns(timelineResult);
_textAnalysisServiceMock
.Setup(x => x.Analyse(It.IsAny<CVData>()))
.Returns(new TextAnalysisResult());
} }
private static CVData CreateTestCVData(int employmentCount = 1) private static CVData CreateTestCVData(int employmentCount = 1)

View File

@@ -8,14 +8,14 @@ public sealed class EducationVerifierServiceTests
{ {
private readonly EducationVerifierService _sut = new(); private readonly EducationVerifierService _sut = new();
#region Diploma Mill Detection #region Unaccredited Institution Detection
[Theory] [Theory]
[InlineData("Belford University")] [InlineData("Belford University")]
[InlineData("Ashwood University")] [InlineData("Ashwood University")]
[InlineData("Rochville University")] [InlineData("Rochville University")]
[InlineData("St Regis University")] [InlineData("St Regis University")]
public void Verify_DiplomaMillInstitution_ReturnsDiplomaMill(string institution) public void Verify_UnaccreditedInstitution_ReturnsUnaccredited(string institution)
{ {
// Arrange // Arrange
var education = new EducationEntry var education = new EducationEntry
@@ -31,14 +31,14 @@ public sealed class EducationVerifierServiceTests
var result = _sut.Verify(education); var result = _sut.Verify(education);
// Assert // Assert
result.Status.Should().Be("DiplomaMill"); result.Status.Should().Be("Unaccredited");
result.IsDiplomaMill.Should().BeTrue(); result.IsUnaccredited.Should().BeTrue();
result.IsSuspicious.Should().BeTrue(); result.IsSuspicious.Should().BeTrue();
result.IsVerified.Should().BeFalse(); result.IsVerified.Should().BeFalse();
} }
[Fact] [Fact]
public void Verify_DiplomaMillInstitution_IncludesVerificationNotes() public void Verify_UnaccreditedInstitution_IncludesVerificationNotes()
{ {
// Arrange // Arrange
var education = new EducationEntry var education = new EducationEntry
@@ -51,7 +51,7 @@ public sealed class EducationVerifierServiceTests
var result = _sut.Verify(education); var result = _sut.Verify(education);
// Assert // Assert
result.VerificationNotes.Should().Contain("diploma mill blacklist"); result.VerificationNotes.Should().Contain("QAA/HESA register");
} }
#endregion #endregion
@@ -76,10 +76,34 @@ public sealed class EducationVerifierServiceTests
// Assert // Assert
result.Status.Should().Be("Suspicious"); result.Status.Should().Be("Suspicious");
result.IsSuspicious.Should().BeTrue(); result.IsSuspicious.Should().BeTrue();
result.IsDiplomaMill.Should().BeFalse(); result.IsUnaccredited.Should().BeFalse();
result.IsVerified.Should().BeFalse(); result.IsVerified.Should().BeFalse();
} }
[Theory]
[InlineData("University of the Peak District")]
[InlineData("University of the Cotswolds")]
[InlineData("University of the Lake District")]
[InlineData("University of the Dales")]
[InlineData("Sheffield Metropolitan University")] // Uses UK pattern but doesn't exist
public void Verify_FakeUKInstitution_ReturnsSuspicious(string institution)
{
// Arrange
var education = new EducationEntry
{
Institution = institution
};
// Act
var result = _sut.Verify(education);
// Assert
result.Status.Should().Be("Suspicious");
result.IsSuspicious.Should().BeTrue();
result.IsVerified.Should().BeFalse();
result.VerificationNotes.Should().Contain("UK university naming convention");
}
#endregion #endregion
#region UK Institution Recognition #region UK Institution Recognition
@@ -109,7 +133,7 @@ public sealed class EducationVerifierServiceTests
// Assert // Assert
result.Status.Should().Be("Recognised"); result.Status.Should().Be("Recognised");
result.IsVerified.Should().BeTrue(); result.IsVerified.Should().BeTrue();
result.IsDiplomaMill.Should().BeFalse(); result.IsUnaccredited.Should().BeFalse();
result.IsSuspicious.Should().BeFalse(); result.IsSuspicious.Should().BeFalse();
result.MatchedInstitution.Should().Be(expectedMatch); result.MatchedInstitution.Should().Be(expectedMatch);
} }
@@ -167,7 +191,7 @@ public sealed class EducationVerifierServiceTests
// Assert // Assert
result.Status.Should().Be("Unknown"); result.Status.Should().Be("Unknown");
result.IsVerified.Should().BeFalse(); result.IsVerified.Should().BeFalse();
result.IsDiplomaMill.Should().BeFalse(); result.IsUnaccredited.Should().BeFalse();
result.IsSuspicious.Should().BeFalse(); result.IsSuspicious.Should().BeFalse();
result.VerificationNotes.Should().Contain("international institution"); result.VerificationNotes.Should().Contain("international institution");
} }
@@ -289,7 +313,7 @@ public sealed class EducationVerifierServiceTests
// Assert // Assert
results.Should().HaveCount(3); results.Should().HaveCount(3);
results[0].Status.Should().Be("Recognised"); results[0].Status.Should().Be("Recognised");
results[1].Status.Should().Be("DiplomaMill"); results[1].Status.Should().Be("Unaccredited");
results[2].Status.Should().Be("Unknown"); results[2].Status.Should().Be("Unknown");
} }