From 3fad43a9e2dc7152964d449ec35856d644a7bf5e Mon Sep 17 00:00:00 2001 From: cesnimda Date: Fri, 3 Jul 2026 03:16:32 +0200 Subject: [PATCH] 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 --- JobTrackerApi.Tests/JobCvMatchServiceTests.cs | 105 +++++++++ .../Controllers/JobApplicationsController.cs | 87 +++++++- JobTrackerApi/Program.cs | 1 + JobTrackerApi/Services/JobCvMatchService.cs | 208 ++++++++++++++++++ .../Services/JobImport/SkillTagger.cs | 6 +- 5 files changed, 404 insertions(+), 3 deletions(-) create mode 100644 JobTrackerApi.Tests/JobCvMatchServiceTests.cs create mode 100644 JobTrackerApi/Services/JobCvMatchService.cs diff --git a/JobTrackerApi.Tests/JobCvMatchServiceTests.cs b/JobTrackerApi.Tests/JobCvMatchServiceTests.cs new file mode 100644 index 0000000..a2c9673 --- /dev/null +++ b/JobTrackerApi.Tests/JobCvMatchServiceTests.cs @@ -0,0 +1,105 @@ +using JobTrackerApi.Services; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class JobCvMatchServiceTests +{ + private readonly JobCvMatchService _service = new(); + + private static Dictionary Sections(params (string Name, string Text)[] items) + => items.ToDictionary(i => i.Name, i => i.Text, StringComparer.OrdinalIgnoreCase); + + [Fact] + public void Strong_overlap_scores_high_and_lists_matched_keywords() + { + var result = _service.Evaluate( + jobTitle: "Senior C# Backend Developer", + jobText: "We need a backend engineer with strong C#, .NET, SQL and Docker experience building REST APIs.", + cvSections: Sections( + ("Skills", "C# .NET SQL Docker Kubernetes"), + ("Experience", "Built REST APIs in C# and .NET with SQL Server and Docker."))); + + Assert.True(result.Score >= 75, $"expected strong score, got {result.Score}"); + Assert.Equal("Strong", result.Band); + Assert.Contains("C#", result.MatchedKeywords); + Assert.Contains(".NET", result.MatchedKeywords); + Assert.True(result.HasEnoughSignal); + } + + [Fact] + public void No_overlap_scores_low_and_surfaces_missing_keywords() + { + var result = _service.Evaluate( + jobTitle: "Kubernetes Platform Engineer", + jobText: "Deep Kubernetes, AWS, and Docker platform experience required. Terraform and CI/CD pipelines.", + cvSections: Sections( + ("Skills", "Graphic design, Adobe Photoshop, Illustrator, copywriting"), + ("Experience", "Ran marketing campaigns and brand design work."))); + + Assert.True(result.Score < 50, $"expected low score, got {result.Score}"); + Assert.Equal("Low", result.Band); + Assert.Contains("Kubernetes", result.MissingKeywords); + Assert.Contains("AWS", result.MissingKeywords); + } + + [Fact] + public void Is_deterministic_for_identical_inputs() + { + var a = _service.Evaluate("Data Engineer", "Python, SQL, Spark, ETL pipelines and AWS.", Sections(("Skills", "Python SQL AWS"))); + var b = _service.Evaluate("Data Engineer", "Python, SQL, Spark, ETL pipelines and AWS.", Sections(("Skills", "Python SQL AWS"))); + + Assert.Equal(a.Score, b.Score); + Assert.Equal(a.MatchedKeywords, b.MatchedKeywords); + Assert.Equal(a.MissingKeywords, b.MissingKeywords); + } + + [Fact] + public void Word_boundary_prevents_false_substring_matches() + { + // "go" (the language) must not match inside "goals"/"ago". + var result = _service.Evaluate( + jobTitle: "Go Developer", + jobText: "Go programming language, goroutines, concurrency.", + cvSections: Sections(("Experience", "Achieved company goals two years ago in a great environment."))); + + Assert.DoesNotContain("go", result.MatchedKeywords, StringComparer.OrdinalIgnoreCase); + } + + [Fact] + public void Section_coverage_reports_where_matches_are_concentrated() + { + var result = _service.Evaluate( + jobTitle: "React Frontend Engineer", + jobText: "Build UIs with React, TypeScript and JavaScript. Strong testing culture.", + cvSections: Sections( + ("Skills", "React TypeScript JavaScript"), + ("Experience", "Wrote documentation and managed budgets."))); + + var skills = Assert.Single(result.SectionCoverage, s => s.Section == "Skills"); + var experience = Assert.Single(result.SectionCoverage, s => s.Section == "Experience"); + Assert.True(skills.Matched > experience.Matched); + } + + [Fact] + public void Empty_cv_reports_no_signal() + { + var result = _service.Evaluate("Anything", "Some role text with several words here.", Sections()); + Assert.False(result.HasEnoughSignal); + Assert.Equal("Unknown", result.Band); + Assert.Equal(0, result.MatchedCount); + } + + [Fact] + public void Title_keywords_are_weighted_and_missing_ones_rank_first() + { + // The title term "kubernetes" is absent from the CV; it should lead the missing list + // because title terms carry the title bonus weight. + var result = _service.Evaluate( + jobTitle: "Kubernetes Specialist", + jobText: "Kubernetes orchestration. Some familiarity with logging and monitoring dashboards.", + cvSections: Sections(("Skills", "logging monitoring dashboards"))); + + Assert.Equal("Kubernetes", result.MissingKeywords.First()); + } +} diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index dc9b788..03deca7 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -23,8 +23,9 @@ namespace JobTrackerApi.Controllers private readonly ILogger _logger; private readonly ICvTemplateRenderer _cvTemplateRenderer; private readonly ICvPdfExporter _cvPdfExporter; + private readonly IJobCvMatchService _matchService; - public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager users, ILogger logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null) + public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager users, ILogger 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 MatchedKeywords, + List MissingKeywords, + List 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 BuildCvSections(ApplicationUser? user) + { + var structured = StructuredCvProfileJson.Deserialize(user?.ProfileCvStructureJson); + var sections = new Dictionary(StringComparer.OrdinalIgnoreCase); + + void Add(string name, IEnumerable 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; + } + + /// + /// Fast, deterministic CV↔job keyword coverage score. Unlike candidate-fit (AI narrative), + /// this makes no model calls, so it returns instantly and reproducibly. + /// + [HttpGet("{id:int}/match-score")] + public async Task> 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> GetCandidateFit([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken) { diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 13400eb..7969d21 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -157,6 +157,7 @@ builder.Services.AddHttpClient("ai-service", client => builder.Services.AddMemoryCache(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/JobTrackerApi/Services/JobCvMatchService.cs b/JobTrackerApi/Services/JobCvMatchService.cs new file mode 100644 index 0000000..7756d69 --- /dev/null +++ b/JobTrackerApi/Services/JobCvMatchService.cs @@ -0,0 +1,208 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; +using JobTrackerApi.Services.JobImport; + +namespace JobTrackerApi.Services +{ + /// One keyword drawn from the job posting and whether the CV covers it. + public sealed record MatchKeyword(string Keyword, int Weight, bool InTitle, bool Matched); + + /// How many of the matched keywords appear in a given CV section. + public sealed record MatchSectionCoverage(string Section, int Matched, int Total); + + public sealed record JobCvMatchResult( + int Score, + string Band, + int MatchedCount, + int TotalKeywords, + IReadOnlyList MatchedKeywords, + IReadOnlyList MissingKeywords, + IReadOnlyList SectionCoverage, + bool HasEnoughSignal); + + public interface IJobCvMatchService + { + JobCvMatchResult Evaluate(string jobTitle, string jobText, IReadOnlyDictionary cvSections); + } + + /// + /// 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. + /// + 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 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 cvSections) + { + jobTitle ??= string.Empty; + jobText ??= string.Empty; + cvSections ??= new Dictionary(); + + 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 BuildKeywords(string jobTitle, string jobText, HashSet titleTokens) + { + var combined = $"{jobTitle}\n{jobText}"; + var byKey = new Dictionary(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(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 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(); + } + } +} diff --git a/JobTrackerApi/Services/JobImport/SkillTagger.cs b/JobTrackerApi/Services/JobImport/SkillTagger.cs index c1c867a..857ea08 100644 --- a/JobTrackerApi/Services/JobImport/SkillTagger.cs +++ b/JobTrackerApi/Services/JobImport/SkillTagger.cs @@ -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(@"(?