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:
@@ -0,0 +1,105 @@
|
||||
using JobTrackerApi.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class JobCvMatchServiceTests
|
||||
{
|
||||
private readonly JobCvMatchService _service = new();
|
||||
|
||||
private static Dictionary<string, string> 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user