feat: deterministic CV-to-job match score endpoint
New JobCvMatchService: a pure, AI-free keyword-coverage scorer that
returns a stable, reproducible 0-100 match score plus matched/missing
keyword lists and per-CV-section coverage. Unlike candidate-fit (AI
narrative), it makes no model calls, so results are instant and
identical for identical inputs - the Jobscan-style differentiator.
- GET /api/jobapplications/{id}/match-score
- keywords = curated SkillTagger tags (high weight) + salient posting
terms (title terms boosted); word-boundary matching avoids false hits
- section coverage shows where CV evidence is concentrated
- fix(SkillTagger): punctuation-tolerant C#/.NET patterns; the old \b
boundaries silently missed 'C#,' and '.NET,' everywhere they are used
- 7 unit tests on the pure scorer; full backend suite green (104)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,8 +23,9 @@ namespace JobTrackerApi.Controllers
|
||||
private readonly ILogger<JobApplicationsController> _logger;
|
||||
private readonly ICvTemplateRenderer _cvTemplateRenderer;
|
||||
private readonly ICvPdfExporter _cvPdfExporter;
|
||||
private readonly IJobCvMatchService _matchService;
|
||||
|
||||
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null)
|
||||
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, IJobCvMatchService? matchService = null)
|
||||
{
|
||||
_db = db;
|
||||
_summarizer = summarizer;
|
||||
@@ -33,6 +34,7 @@ namespace JobTrackerApi.Controllers
|
||||
_logger = logger;
|
||||
_cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer();
|
||||
_cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter();
|
||||
_matchService = matchService ?? new JobCvMatchService();
|
||||
}
|
||||
|
||||
private sealed class ThrowingCvPdfExporter : ICvPdfExporter
|
||||
@@ -2107,6 +2109,89 @@ Canonical profile:
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record MatchScoreDto(
|
||||
int Score,
|
||||
string Band,
|
||||
int MatchedCount,
|
||||
int TotalKeywords,
|
||||
List<string> MatchedKeywords,
|
||||
List<string> MissingKeywords,
|
||||
List<MatchSectionCoverageDto> SectionCoverage,
|
||||
bool HasEnoughSignal);
|
||||
|
||||
public sealed record MatchSectionCoverageDto(string Section, int Matched, int Total);
|
||||
|
||||
// Builds CV text grouped by section so match coverage can show *where* the evidence sits.
|
||||
private static Dictionary<string, string> BuildCvSections(ApplicationUser? user)
|
||||
{
|
||||
var structured = StructuredCvProfileJson.Deserialize(user?.ProfileCvStructureJson);
|
||||
var sections = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
void Add(string name, IEnumerable<string?> values)
|
||||
{
|
||||
var text = string.Join("\n", values.Where(v => !string.IsNullOrWhiteSpace(v)));
|
||||
if (!string.IsNullOrWhiteSpace(text)) sections[name] = text;
|
||||
}
|
||||
|
||||
Add("Summary", new[] { structured.Contact.Headline }.Concat(structured.Summary));
|
||||
Add("Skills", structured.Skills);
|
||||
Add("Experience", structured.Jobs.SelectMany(job =>
|
||||
new[] { job.Title, job.Company }.Concat(job.Bullets).Concat(job.Skills)));
|
||||
Add("Education", structured.Education.SelectMany(ed =>
|
||||
new[] { ed.Qualification, ed.Institution }.Concat(ed.Details)));
|
||||
|
||||
// Always include raw profile text (covers users who only pasted plain CV text, and
|
||||
// catches keywords the structured sections missed).
|
||||
if (!string.IsNullOrWhiteSpace(user?.ProfileCvText))
|
||||
{
|
||||
sections["Profile"] = user!.ProfileCvText!;
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fast, deterministic CV↔job keyword coverage score. Unlike candidate-fit (AI narrative),
|
||||
/// this makes no model calls, so it returns instantly and reproducibly.
|
||||
/// </summary>
|
||||
[HttpGet("{id:int}/match-score")]
|
||||
public async Task<ActionResult<MatchScoreDto>> GetMatchScore([FromRoute] int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await _db.JobApplications
|
||||
.Include(j => j.Company)
|
||||
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||
if (job is null) return NotFound();
|
||||
|
||||
var userId = CurrentUserId;
|
||||
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
|
||||
|
||||
var user = await _db.Users.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
||||
var cvSections = BuildCvSections(user);
|
||||
if (cvSections.Count == 0)
|
||||
{
|
||||
return BadRequest("Add your profile CV on the Profile page before running the match score.");
|
||||
}
|
||||
|
||||
var jobText = string.Join("\n\n", new[] { job.Description, job.TranslatedDescription, job.Notes }
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
if (string.IsNullOrWhiteSpace(jobText))
|
||||
{
|
||||
return BadRequest("This job does not have enough description or notes to compare against your CV.");
|
||||
}
|
||||
|
||||
var result = _matchService.Evaluate(job.JobTitle, jobText, cvSections);
|
||||
|
||||
return Ok(new MatchScoreDto(
|
||||
Score: result.Score,
|
||||
Band: result.Band,
|
||||
MatchedCount: result.MatchedCount,
|
||||
TotalKeywords: result.TotalKeywords,
|
||||
MatchedKeywords: result.MatchedKeywords.ToList(),
|
||||
MissingKeywords: result.MissingKeywords.ToList(),
|
||||
SectionCoverage: result.SectionCoverage.Select(s => new MatchSectionCoverageDto(s.Section, s.Matched, s.Total)).ToList(),
|
||||
HasEnoughSignal: result.HasEnoughSignal));
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/candidate-fit")]
|
||||
public async Task<ActionResult<CandidateFitDto>> GetCandidateFit([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -157,6 +157,7 @@ builder.Services.AddHttpClient("ai-service", client =>
|
||||
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddSingleton<ISummarizerService, SummarizerService>();
|
||||
builder.Services.AddSingleton<IJobCvMatchService, JobCvMatchService>();
|
||||
builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>();
|
||||
builder.Services.AddSingleton<ICvAiNormalizer, CvAiNormalizer>();
|
||||
builder.Services.AddSingleton<IGoogleTokenValidator, GoogleTokenValidator>();
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using JobTrackerApi.Services.JobImport;
|
||||
|
||||
namespace JobTrackerApi.Services
|
||||
{
|
||||
/// <summary>One keyword drawn from the job posting and whether the CV covers it.</summary>
|
||||
public sealed record MatchKeyword(string Keyword, int Weight, bool InTitle, bool Matched);
|
||||
|
||||
/// <summary>How many of the matched keywords appear in a given CV section.</summary>
|
||||
public sealed record MatchSectionCoverage(string Section, int Matched, int Total);
|
||||
|
||||
public sealed record JobCvMatchResult(
|
||||
int Score,
|
||||
string Band,
|
||||
int MatchedCount,
|
||||
int TotalKeywords,
|
||||
IReadOnlyList<string> MatchedKeywords,
|
||||
IReadOnlyList<string> MissingKeywords,
|
||||
IReadOnlyList<MatchSectionCoverage> SectionCoverage,
|
||||
bool HasEnoughSignal);
|
||||
|
||||
public interface IJobCvMatchService
|
||||
{
|
||||
JobCvMatchResult Evaluate(string jobTitle, string jobText, IReadOnlyDictionary<string, string> cvSections);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic CV↔job keyword coverage score. No AI: the same inputs always produce the
|
||||
/// same number so users get a stable, reproducible signal (the Jobscan-style differentiator).
|
||||
/// The AI narrative lives separately in the candidate-fit endpoint.
|
||||
/// </summary>
|
||||
public sealed class JobCvMatchService : IJobCvMatchService
|
||||
{
|
||||
// Weights: curated skill tags are high-signal; salient posting terms are the long tail.
|
||||
private const int CuratedTagWeight = 3;
|
||||
private const int TermWeight = 1;
|
||||
private const int TitleBonus = 2;
|
||||
private const int MaxKeywords = 28;
|
||||
|
||||
private static readonly Regex TokenPattern = new(@"[a-z0-9][a-z0-9+.#-]*", RegexOptions.Compiled);
|
||||
|
||||
private static readonly HashSet<string> StopWords = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"the", "and", "for", "with", "you", "your", "our", "are", "will", "have", "has", "that",
|
||||
"this", "from", "not", "but", "all", "can", "who", "how", "why", "what", "when", "who",
|
||||
"job", "role", "work", "working", "team", "teams", "company", "years", "year", "experience",
|
||||
"experienced", "skills", "skill", "ability", "able", "strong", "good", "great", "excellent",
|
||||
"including", "include", "includes", "well", "using", "use", "used", "within", "across",
|
||||
"into", "onto", "their", "them", "they", "were", "was", "would", "should", "could", "must",
|
||||
"new", "also", "per", "via", "etc", "such", "any", "one", "two", "three", "day", "days",
|
||||
"week", "weeks", "month", "months", "time", "full", "part", "based", "join", "looking",
|
||||
"seeking", "candidate", "candidates", "applicant", "position", "positions", "opportunity",
|
||||
"responsibilities", "requirements", "required", "preferred", "plus", "nice", "want", "need",
|
||||
"needs", "help", "make", "made", "get", "got", "more", "most", "many", "much", "each",
|
||||
"other", "others", "some", "than", "then", "there", "here", "about", "over", "under", "out",
|
||||
"off", "its", "his", "her", "she", "him", "may", "might", "high", "low", "level", "levels",
|
||||
"environment", "environments", "world", "people", "person", "customer", "customers", "client",
|
||||
"clients", "product", "products", "service", "services", "business", "solution", "solutions",
|
||||
"project", "projects", "process", "processes", "development", "develop", "developer",
|
||||
// Seniority / role-title words: noise for CV keyword matching (the hard skills are what count).
|
||||
"senior", "junior", "lead", "principal", "mid", "staff", "engineer", "engineers",
|
||||
"engineering", "manager", "specialist", "analyst", "consultant", "administrator",
|
||||
"coordinator", "associate", "intern", "officer", "director", "professional",
|
||||
};
|
||||
|
||||
public JobCvMatchResult Evaluate(string jobTitle, string jobText, IReadOnlyDictionary<string, string> cvSections)
|
||||
{
|
||||
jobTitle ??= string.Empty;
|
||||
jobText ??= string.Empty;
|
||||
cvSections ??= new Dictionary<string, string>();
|
||||
|
||||
var titleTokens = Tokenize(jobTitle).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var keywords = BuildKeywords(jobTitle, jobText, titleTokens);
|
||||
|
||||
// Combine all CV sections into one searchable corpus, plus keep per-section text for coverage.
|
||||
var sectionCorpora = cvSections
|
||||
.Where(kvp => !string.IsNullOrWhiteSpace(kvp.Value))
|
||||
.ToDictionary(kvp => kvp.Key, kvp => Normalize(kvp.Value), StringComparer.OrdinalIgnoreCase);
|
||||
var fullCorpus = string.Join(" \n ", sectionCorpora.Values);
|
||||
|
||||
var evaluated = keywords
|
||||
.Select(k => k with { Matched = CorpusContains(fullCorpus, k.Keyword) })
|
||||
.ToList();
|
||||
|
||||
var totalWeight = evaluated.Sum(k => k.Weight);
|
||||
var matchedWeight = evaluated.Where(k => k.Matched).Sum(k => k.Weight);
|
||||
var hasEnoughSignal = evaluated.Count >= 3 && sectionCorpora.Count > 0;
|
||||
|
||||
var score = totalWeight == 0 ? 0 : (int)Math.Round(100.0 * matchedWeight / totalWeight, MidpointRounding.AwayFromZero);
|
||||
score = Math.Clamp(score, 0, 100);
|
||||
|
||||
var band = !hasEnoughSignal ? "Unknown" : score >= 75 ? "Strong" : score >= 50 ? "Partial" : "Low";
|
||||
|
||||
var matchedKeywords = evaluated.Where(k => k.Matched)
|
||||
.OrderByDescending(k => k.Weight).ThenBy(k => k.Keyword, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(k => k.Keyword).ToList();
|
||||
var missingKeywords = evaluated.Where(k => !k.Matched)
|
||||
.OrderByDescending(k => k.Weight).ThenBy(k => k.Keyword, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(k => k.Keyword).ToList();
|
||||
|
||||
var sectionCoverage = sectionCorpora
|
||||
.Select(section => new MatchSectionCoverage(
|
||||
section.Key,
|
||||
evaluated.Count(k => CorpusContains(section.Value, k.Keyword)),
|
||||
evaluated.Count))
|
||||
.Where(sc => sc.Total > 0)
|
||||
.OrderByDescending(sc => sc.Matched)
|
||||
.ToList();
|
||||
|
||||
return new JobCvMatchResult(
|
||||
Score: score,
|
||||
Band: band,
|
||||
MatchedCount: matchedKeywords.Count,
|
||||
TotalKeywords: evaluated.Count,
|
||||
MatchedKeywords: matchedKeywords,
|
||||
MissingKeywords: missingKeywords,
|
||||
SectionCoverage: sectionCoverage,
|
||||
HasEnoughSignal: hasEnoughSignal);
|
||||
}
|
||||
|
||||
private static List<MatchKeyword> BuildKeywords(string jobTitle, string jobText, HashSet<string> titleTokens)
|
||||
{
|
||||
var combined = $"{jobTitle}\n{jobText}";
|
||||
var byKey = new Dictionary<string, MatchKeyword>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// 1) Curated skill tags: high-signal, canonical spelling.
|
||||
foreach (var tag in SkillTagger.Detect(combined))
|
||||
{
|
||||
var inTitle = TitleContains(jobTitle, tag);
|
||||
byKey[tag] = new MatchKeyword(tag, CuratedTagWeight + (inTitle ? TitleBonus : 0), inTitle, false);
|
||||
}
|
||||
|
||||
// 2) Salient posting terms: frequency-ranked content words from the description.
|
||||
var frequencies = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var token in Tokenize(jobText))
|
||||
{
|
||||
if (token.Length < 3 || StopWords.Contains(token) || IsNumeric(token)) continue;
|
||||
frequencies[token] = frequencies.TryGetValue(token, out var c) ? c + 1 : 1;
|
||||
}
|
||||
|
||||
var rankedTerms = frequencies
|
||||
.Where(kvp => kvp.Value >= 1)
|
||||
.OrderByDescending(kvp => titleTokens.Contains(kvp.Key) ? 1 : 0)
|
||||
.ThenByDescending(kvp => kvp.Value)
|
||||
.ThenBy(kvp => kvp.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(kvp => kvp.Key);
|
||||
|
||||
foreach (var term in rankedTerms)
|
||||
{
|
||||
if (byKey.Count >= MaxKeywords) break;
|
||||
if (byKey.ContainsKey(term)) continue;
|
||||
var inTitle = titleTokens.Contains(term);
|
||||
byKey[term] = new MatchKeyword(term, TermWeight + (inTitle ? TitleBonus : 0), inTitle, false);
|
||||
}
|
||||
|
||||
return byKey.Values
|
||||
.OrderByDescending(k => k.Weight)
|
||||
.ThenBy(k => k.Keyword, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(MaxKeywords)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static bool TitleContains(string title, string phrase)
|
||||
=> Normalize(title).Contains(Normalize(phrase), StringComparison.Ordinal);
|
||||
|
||||
private static bool CorpusContains(string normalizedCorpus, string keyword)
|
||||
{
|
||||
var needle = Normalize(keyword);
|
||||
if (needle.Length == 0) return false;
|
||||
// Word-boundary-ish match to avoid "go" matching "goal".
|
||||
var idx = normalizedCorpus.IndexOf(needle, StringComparison.Ordinal);
|
||||
while (idx >= 0)
|
||||
{
|
||||
var beforeOk = idx == 0 || !char.IsLetterOrDigit(normalizedCorpus[idx - 1]);
|
||||
var afterPos = idx + needle.Length;
|
||||
var afterOk = afterPos >= normalizedCorpus.Length || !char.IsLetterOrDigit(normalizedCorpus[afterPos]);
|
||||
if (beforeOk && afterOk) return true;
|
||||
idx = normalizedCorpus.IndexOf(needle, idx + 1, StringComparison.Ordinal);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> Tokenize(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) yield break;
|
||||
foreach (Match m in TokenPattern.Matches(text.ToLowerInvariant()))
|
||||
{
|
||||
yield return m.Value.Trim('-', '.', '+', '#');
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsNumeric(string token)
|
||||
=> token.All(c => char.IsDigit(c) || c is '.' or '-' or '+');
|
||||
|
||||
private static string Normalize(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return string.Empty;
|
||||
var sb = new StringBuilder(text.Length);
|
||||
foreach (var ch in text.ToLowerInvariant())
|
||||
{
|
||||
sb.Append(char.IsWhiteSpace(ch) ? ' ' : ch);
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,10 @@ public static class SkillTagger
|
||||
{
|
||||
private static readonly (string Tag, Regex Pattern, int Weight)[] Patterns =
|
||||
{
|
||||
("C#", new Regex(@"\bC#\b|\bcsharp\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
|
||||
(".NET", new Regex(@"\b\.NET\b|\bASP\.NET\b|\bDOTNET\b|\bEntity Framework\b|\bEF Core\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
|
||||
// Symbol skills need punctuation-tolerant boundaries: \b fails next to '#'/'.'
|
||||
// (both non-word chars), which previously left "C#," and ".NET," undetected.
|
||||
("C#", new Regex(@"(?<![A-Za-z0-9#])C#|\bc[-\s]?sharp\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
|
||||
(".NET", new Regex(@"(?<![A-Za-z0-9.])\.NET\b|\bASP\.NET\b|\bDOTNET\b|\bEntity Framework\b|\bEF Core\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
|
||||
("Python", new Regex(@"\bPython\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
|
||||
("Java", new Regex(@"\bJava\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
|
||||
("JavaScript", new Regex(@"\bJavaScript\b|\bJS\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
|
||||
|
||||
Reference in New Issue
Block a user