Compare commits
11 Commits
8a4e46d872
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| fab1866fc8 | |||
| 0dc03dd380 | |||
| 0c42842655 | |||
| 49e4f74768 | |||
| 2575e2be95 | |||
| a132efd907 | |||
| f775164212 | |||
| 72b7f11c41 | |||
| ff09524503 | |||
| 9ec96d4af7 | |||
| 5d2965beae |
31
deploy-local.sh
Executable 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
|
After Width: | Height: | Size: 646 KiB |
BIN
screenshots/02-login.png
Normal file
|
After Width: | Height: | Size: 433 KiB |
BIN
screenshots/03-register.png
Normal file
|
After Width: | Height: | Size: 444 KiB |
BIN
screenshots/04-dashboard.png
Normal file
|
After Width: | Height: | Size: 218 KiB |
BIN
screenshots/05-check.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
screenshots/06-report.png
Normal file
|
After Width: | Height: | Size: 266 KiB |
BIN
screenshots/dashboard-compact.png
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
screenshots/dashboard-warm.png
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
screenshots/report-compact.png
Normal file
|
After Width: | Height: | Size: 165 KiB |
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
36
src/RealCV.Application/Interfaces/IGitHubVerifierService.cs
Normal 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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using RealCV.Application.Models;
|
||||||
|
|
||||||
|
namespace RealCV.Application.Interfaces;
|
||||||
|
|
||||||
|
public interface ITextAnalysisService
|
||||||
|
{
|
||||||
|
TextAnalysisResult Analyse(CVData cvData);
|
||||||
|
}
|
||||||
62
src/RealCV.Application/Models/AcademicVerificationResult.cs
Normal 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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
49
src/RealCV.Application/Models/GitHubVerificationResult.cs
Normal 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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|||||||
66
src/RealCV.Application/Models/TextAnalysisResult.cs
Normal 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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
210
src/RealCV.Infrastructure/Clients/FcaRegisterClient.cs
Normal 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; }
|
||||||
|
}
|
||||||
237
src/RealCV.Infrastructure/Clients/GitHubClient.cs
Normal 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; }
|
||||||
|
}
|
||||||
336
src/RealCV.Infrastructure/Clients/OrcidClient.cs
Normal 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; }
|
||||||
|
}
|
||||||
505
src/RealCV.Infrastructure/Data/Migrations/20260125074319_AddTermsAcceptedAt.Designer.cs
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
509
src/RealCV.Infrastructure/Services/AcademicVerifierService.cs
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
275
src/RealCV.Infrastructure/Services/GitHubVerifierService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
593
src/RealCV.Infrastructure/Services/TextAnalysisService.cs
Normal 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
|
||||||
|
}
|
||||||
@@ -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">© @DateTime.Now.Year RealCV. All rights reserved.</p>
|
<p class="mb-2">© @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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
144
src/RealCV.Web/Components/Pages/Privacy.razor
Normal 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>
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
162
src/RealCV.Web/Components/Pages/Terms.razor
Normal 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>
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 26 KiB |
@@ -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)
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||