Compare commits

4 Commits

Author SHA1 Message Date
72b7f11c41 refactor: Remove SRA integration (no public API available)
The SRA (Solicitors Regulation Authority) does not provide a public REST API.
Their register is only accessible via their website. Removed all SRA-related
code and added ApiTester tool for testing remaining integrations.

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,52 @@
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 int TotalLinesOfCode { get; init; }
public DateOnly? FirstUsed { get; init; }
public DateOnly? LastUsed { get; init; }
public string? Notes { get; init; }
}
public sealed record GitHubVerificationFlag
{
public required string Type { get; init; }
public required string Severity { get; init; }
public required string Message { get; init; }
public int ScoreImpact { get; init; }
}

View File

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

View File

@@ -0,0 +1,216 @@
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
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>(JsonOptions);
}
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>(JsonOptions);
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>(JsonOptions);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching FCA for firm: {Name}", name);
return null;
}
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
}
public class FcaOptions
{
public string ApiKey { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
}
// Response models
public class FcaIndividualResponse
{
public List<FcaIndividualSearchItem>? Data { get; set; }
public FcaPagination? Pagination { get; set; }
}
public class FcaIndividualSearchItem
{
[JsonPropertyName("Individual Reference Number")]
public string? IndividualReferenceNumber { get; set; }
public string? Name { get; set; }
public string? Status { get; set; }
[JsonPropertyName("Current Employer(s)")]
public string? CurrentEmployers { get; set; }
}
public class FcaIndividualDetailsWrapper
{
public List<FcaIndividualDetails>? Data { get; set; }
}
public class FcaIndividualDetails
{
[JsonPropertyName("Individual Reference Number")]
public string? IndividualReferenceNumber { get; set; }
public string? Name { get; set; }
public string? Status { get; set; }
[JsonPropertyName("Effective Date")]
public string? EffectiveDate { get; set; }
[JsonPropertyName("Controlled Functions")]
public List<FcaControlledFunction>? ControlledFunctions { get; set; }
[JsonPropertyName("Previous Employments")]
public List<FcaPreviousEmployment>? PreviousEmployments { get; set; }
}
public class FcaControlledFunction
{
[JsonPropertyName("Controlled Function")]
public string? ControlledFunction { get; set; }
[JsonPropertyName("Firm Name")]
public string? FirmName { get; set; }
[JsonPropertyName("Firm Reference Number")]
public string? FirmReferenceNumber { get; set; }
[JsonPropertyName("Status")]
public string? Status { get; set; }
[JsonPropertyName("Effective From")]
public string? EffectiveFrom { get; set; }
}
public class FcaPreviousEmployment
{
[JsonPropertyName("Firm Name")]
public string? FirmName { get; set; }
[JsonPropertyName("Firm Reference Number")]
public string? FirmReferenceNumber { get; set; }
[JsonPropertyName("Start Date")]
public string? StartDate { get; set; }
[JsonPropertyName("End Date")]
public string? EndDate { get; set; }
}
public class FcaFirmResponse
{
public List<FcaFirmSearchItem>? Data { get; set; }
public FcaPagination? Pagination { get; set; }
}
public class FcaFirmSearchItem
{
[JsonPropertyName("Firm Reference Number")]
public string? FirmReferenceNumber { get; set; }
[JsonPropertyName("Firm Name")]
public string? FirmName { get; set; }
public string? Status { get; set; }
}
public class FcaPagination
{
public int Page { get; set; }
public int TotalPages { get; set; }
public int TotalItems { get; set; }
}

View File

@@ -0,0 +1,265 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
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>(JsonOptions);
}
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>>(JsonOptions);
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<Dictionary<string, int>?> GetRepoLanguagesAsync(string owner, string repo)
{
try
{
var url = $"repos/{Uri.EscapeDataString(owner)}/{Uri.EscapeDataString(repo)}/languages";
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
return null;
}
return await response.Content.ReadFromJsonAsync<Dictionary<string, int>>(JsonOptions);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting languages for repo: {Owner}/{Repo}", owner, repo);
return null;
}
}
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>(JsonOptions);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching GitHub users: {Query}", query);
return null;
}
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
}
public class GitHubOptions
{
public string PersonalAccessToken { get; set; } = string.Empty;
}
// Response models
public class GitHubUser
{
public string? Login { get; set; }
public int Id { get; set; }
public string? Name { get; set; }
public string? Company { get; set; }
public string? Blog { get; set; }
public string? Location { get; set; }
public string? Email { get; set; }
public string? Bio { get; set; }
[JsonPropertyName("twitter_username")]
public string? TwitterUsername { get; set; }
[JsonPropertyName("public_repos")]
public int PublicRepos { get; set; }
[JsonPropertyName("public_gists")]
public int PublicGists { get; set; }
public int Followers { get; set; }
public int Following { get; set; }
[JsonPropertyName("created_at")]
public DateTime CreatedAt { get; set; }
[JsonPropertyName("updated_at")]
public DateTime UpdatedAt { get; set; }
[JsonPropertyName("html_url")]
public string? HtmlUrl { get; set; }
[JsonPropertyName("avatar_url")]
public string? AvatarUrl { get; set; }
}
public class GitHubRepo
{
public int Id { get; set; }
public string? Name { get; set; }
[JsonPropertyName("full_name")]
public string? FullName { get; set; }
public string? Description { get; set; }
public string? Language { get; set; }
[JsonPropertyName("html_url")]
public string? HtmlUrl { get; set; }
public bool Fork { get; set; }
public bool Private { get; set; }
[JsonPropertyName("stargazers_count")]
public int StargazersCount { get; set; }
[JsonPropertyName("watchers_count")]
public int WatchersCount { get; set; }
[JsonPropertyName("forks_count")]
public int ForksCount { get; set; }
public int Size { get; set; }
[JsonPropertyName("created_at")]
public DateTime CreatedAt { get; set; }
[JsonPropertyName("updated_at")]
public DateTime UpdatedAt { get; set; }
[JsonPropertyName("pushed_at")]
public DateTime? PushedAt { get; set; }
}
public class GitHubUserSearchResponse
{
[JsonPropertyName("total_count")]
public int TotalCount { get; set; }
[JsonPropertyName("incomplete_results")]
public bool IncompleteResults { get; set; }
public List<GitHubUserSearchItem>? Items { get; set; }
}
public class GitHubUserSearchItem
{
public string? Login { get; set; }
public int Id { get; set; }
[JsonPropertyName("avatar_url")]
public string? AvatarUrl { get; set; }
[JsonPropertyName("html_url")]
public string? HtmlUrl { get; set; }
public double Score { get; set; }
}

View File

@@ -0,0 +1,342 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
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>(JsonOptions);
}
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>(JsonOptions);
}
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>(JsonOptions);
}
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>(JsonOptions);
}
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>(JsonOptions);
}
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;
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
}
// Response models
public class OrcidSearchResponse
{
[JsonPropertyName("num-found")]
public int NumFound { get; set; }
public List<OrcidSearchResult>? Result { get; set; }
}
public class OrcidSearchResult
{
[JsonPropertyName("orcid-identifier")]
public OrcidIdentifier? OrcidIdentifier { get; set; }
}
public class OrcidIdentifier
{
public string? Uri { get; set; }
public string? Path { get; set; }
public string? Host { get; set; }
}
public class OrcidRecord
{
[JsonPropertyName("orcid-identifier")]
public OrcidIdentifier? OrcidIdentifier { get; set; }
public OrcidPerson? Person { get; set; }
[JsonPropertyName("activities-summary")]
public OrcidActivitiesSummary? ActivitiesSummary { get; set; }
}
public class OrcidPerson
{
public OrcidName? Name { get; set; }
public OrcidBiography? Biography { get; set; }
}
public class OrcidName
{
[JsonPropertyName("given-names")]
public OrcidValue? GivenNames { get; set; }
[JsonPropertyName("family-name")]
public OrcidValue? FamilyName { get; set; }
[JsonPropertyName("credit-name")]
public OrcidValue? CreditName { get; set; }
}
public class OrcidValue
{
public string? Value { get; set; }
}
public class OrcidBiography
{
public string? Content { get; set; }
}
public class OrcidActivitiesSummary
{
public OrcidEmployments? Employments { get; set; }
public OrcidEducations? Educations { get; set; }
public OrcidWorks? Works { get; set; }
}
public class OrcidEmployments
{
[JsonPropertyName("affiliation-group")]
public List<OrcidAffiliationGroup>? AffiliationGroup { get; set; }
}
public class OrcidEducations
{
[JsonPropertyName("affiliation-group")]
public List<OrcidAffiliationGroup>? AffiliationGroup { get; set; }
}
public class OrcidAffiliationGroup
{
public List<OrcidAffiliationSummaryWrapper>? Summaries { get; set; }
}
public class OrcidAffiliationSummaryWrapper
{
[JsonPropertyName("employment-summary")]
public OrcidAffiliationSummary? EmploymentSummary { get; set; }
[JsonPropertyName("education-summary")]
public OrcidAffiliationSummary? EducationSummary { get; set; }
}
public class OrcidAffiliationSummary
{
[JsonPropertyName("department-name")]
public string? DepartmentName { get; set; }
[JsonPropertyName("role-title")]
public string? RoleTitle { get; set; }
[JsonPropertyName("start-date")]
public OrcidDate? StartDate { get; set; }
[JsonPropertyName("end-date")]
public OrcidDate? EndDate { get; set; }
public OrcidOrganization? Organization { get; set; }
}
public class OrcidDate
{
public OrcidValue? Year { get; set; }
public OrcidValue? Month { get; set; }
public OrcidValue? Day { get; set; }
}
public class OrcidOrganization
{
public string? Name { get; set; }
public OrcidAddress? Address { get; set; }
}
public class OrcidAddress
{
public string? City { get; set; }
public string? Region { get; set; }
public string? Country { get; set; }
}
public class OrcidWorks
{
public List<OrcidWorkGroup>? Group { get; set; }
}
public class OrcidWorkGroup
{
[JsonPropertyName("work-summary")]
public List<OrcidWorkSummary>? WorkSummary { get; set; }
}
public class OrcidWorkSummary
{
public string? Title { get; set; }
public OrcidTitle? TitleObj { get; set; }
public string? Type { get; set; }
[JsonPropertyName("publication-date")]
public OrcidDate? PublicationDate { get; set; }
[JsonPropertyName("journal-title")]
public OrcidValue? JournalTitle { get; set; }
[JsonPropertyName("external-ids")]
public OrcidExternalIds? ExternalIds { get; set; }
}
public class OrcidTitle
{
public OrcidValue? Title { get; set; }
}
public class OrcidExternalIds
{
[JsonPropertyName("external-id")]
public List<OrcidExternalId>? ExternalId { get; set; }
}
public class OrcidExternalId
{
[JsonPropertyName("external-id-type")]
public string? ExternalIdType { get; set; }
[JsonPropertyName("external-id-value")]
public string? ExternalIdValue { get; set; }
}

View File

@@ -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,6 +96,18 @@ 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>();
@@ -98,6 +118,11 @@ public static class DependencyInjection
services.AddScoped<IUserContextService, UserContextService>(); services.AddScoped<IUserContextService, UserContextService>();
services.AddScoped<IAuditService, AuditService>(); services.AddScoped<IAuditService, AuditService>();
// Register additional verification services
services.AddScoped<IProfessionalVerifierService, ProfessionalVerifierService>();
services.AddScoped<IGitHubVerifierService, GitHubVerifierService>();
services.AddScoped<IAcademicVerifierService, AcademicVerifierService>();
// Register file storage - use local storage if configured, otherwise Azure // Register file storage - use local storage if configured, otherwise Azure
var useLocalStorage = configuration.GetValue<bool>("UseLocalStorage"); var useLocalStorage = configuration.GetValue<bool>("UseLocalStorage");
if (useLocalStorage) if (useLocalStorage)

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

123
tools/ApiTester/Program.cs Normal file
View File

@@ -0,0 +1,123 @@
using System.Net.Http.Headers;
using System.Text.Json;
Console.WriteLine("=== RealCV API Integration Tester ===\n");
// Test 1: FCA Register API
Console.WriteLine("1. Testing FCA Register API...");
try
{
var fcaClient = new HttpClient();
fcaClient.BaseAddress = new Uri("https://register.fca.org.uk/services/V0.1/");
fcaClient.DefaultRequestHeaders.Add("X-Auth-Email", "peter.foster@ukdataservices.co.uk");
fcaClient.DefaultRequestHeaders.Add("X-Auth-Key", "9ae1aee51e5c717a1135775501c89075");
var fcaResponse = await fcaClient.GetAsync("Individuals?q=John%20Smith&page=1");
Console.WriteLine($" Status: {fcaResponse.StatusCode}");
if (fcaResponse.IsSuccessStatusCode)
{
var content = await fcaResponse.Content.ReadAsStringAsync();
Console.WriteLine($" ✓ FCA API working");
using var doc = JsonDocument.Parse(content);
if (doc.RootElement.TryGetProperty("Data", out var data) && data.ValueKind == JsonValueKind.Array)
{
Console.WriteLine($" Found {data.GetArrayLength()} individuals matching 'John Smith'");
}
else
{
Console.WriteLine($" Response: {content.Substring(0, Math.Min(200, content.Length))}");
}
}
else
{
Console.WriteLine($" ✗ Error: {fcaResponse.StatusCode}");
}
}
catch (Exception ex)
{
Console.WriteLine($" ✗ Error: {ex.Message}");
}
Console.WriteLine();
// Test 2: ORCID API
Console.WriteLine("2. Testing ORCID API...");
try
{
var orcidClient = new HttpClient();
orcidClient.BaseAddress = new Uri("https://pub.orcid.org/v3.0/");
orcidClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
// Get a known ORCID record directly
var orcidResponse = await orcidClient.GetAsync("0000-0001-5109-3700/record");
Console.WriteLine($" Status: {orcidResponse.StatusCode}");
if (orcidResponse.IsSuccessStatusCode)
{
var content = await orcidResponse.Content.ReadAsStringAsync();
Console.WriteLine($" ✓ ORCID API working");
using var doc = JsonDocument.Parse(content);
if (doc.RootElement.TryGetProperty("person", out var person) &&
person.TryGetProperty("name", out var name))
{
var givenName = "";
var familyName = "";
if (name.TryGetProperty("given-names", out var gn) && gn.TryGetProperty("value", out var gnv))
givenName = gnv.GetString() ?? "";
if (name.TryGetProperty("family-name", out var fn) && fn.TryGetProperty("value", out var fnv))
familyName = fnv.GetString() ?? "";
Console.WriteLine($" Retrieved record for: {givenName} {familyName}");
}
}
else
{
Console.WriteLine($" ✗ Error: {orcidResponse.StatusCode}");
}
}
catch (Exception ex)
{
Console.WriteLine($" ✗ Error: {ex.Message}");
}
Console.WriteLine();
// Test 3: GitHub API
Console.WriteLine("3. Testing GitHub API...");
try
{
var githubClient = new HttpClient();
githubClient.BaseAddress = new Uri("https://api.github.com/");
githubClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
githubClient.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28");
githubClient.DefaultRequestHeaders.UserAgent.ParseAdd("RealCV/1.0");
var githubResponse = await githubClient.GetAsync("users/torvalds");
Console.WriteLine($" Status: {githubResponse.StatusCode}");
if (githubResponse.IsSuccessStatusCode)
{
var content = await githubResponse.Content.ReadAsStringAsync();
Console.WriteLine($" ✓ GitHub API working");
using var doc = JsonDocument.Parse(content);
var name = doc.RootElement.GetProperty("name").GetString();
var repos = doc.RootElement.GetProperty("public_repos").GetInt32();
var followers = doc.RootElement.GetProperty("followers").GetInt32();
Console.WriteLine($" User: {name}, Repos: {repos}, Followers: {followers}");
}
else
{
Console.WriteLine($" ✗ Error: {githubResponse.StatusCode}");
}
if (githubResponse.Headers.TryGetValues("X-RateLimit-Remaining", out var remaining))
{
Console.WriteLine($" Rate limit remaining: {remaining.First()}");
}
}
catch (Exception ex)
{
Console.WriteLine($" ✗ Error: {ex.Message}");
}
Console.WriteLine("\n=== Tests complete ===");