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:
cesnimda
2026-07-03 03:16:32 +02:00
parent 83e6430a24
commit 3fad43a9e2
5 changed files with 404 additions and 3 deletions
@@ -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)
{
+1
View File
@@ -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>();
+208
View File
@@ -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),