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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user