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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,8 +23,9 @@ namespace JobTrackerApi.Controllers
|
|||||||
private readonly ILogger<JobApplicationsController> _logger;
|
private readonly ILogger<JobApplicationsController> _logger;
|
||||||
private readonly ICvTemplateRenderer _cvTemplateRenderer;
|
private readonly ICvTemplateRenderer _cvTemplateRenderer;
|
||||||
private readonly ICvPdfExporter _cvPdfExporter;
|
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;
|
_db = db;
|
||||||
_summarizer = summarizer;
|
_summarizer = summarizer;
|
||||||
@@ -33,6 +34,7 @@ namespace JobTrackerApi.Controllers
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
_cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer();
|
_cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer();
|
||||||
_cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter();
|
_cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter();
|
||||||
|
_matchService = matchService ?? new JobCvMatchService();
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class ThrowingCvPdfExporter : ICvPdfExporter
|
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")]
|
[HttpGet("{id:int}/candidate-fit")]
|
||||||
public async Task<ActionResult<CandidateFitDto>> GetCandidateFit([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken)
|
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.AddMemoryCache();
|
||||||
builder.Services.AddSingleton<ISummarizerService, SummarizerService>();
|
builder.Services.AddSingleton<ISummarizerService, SummarizerService>();
|
||||||
|
builder.Services.AddSingleton<IJobCvMatchService, JobCvMatchService>();
|
||||||
builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>();
|
builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>();
|
||||||
builder.Services.AddSingleton<ICvAiNormalizer, CvAiNormalizer>();
|
builder.Services.AddSingleton<ICvAiNormalizer, CvAiNormalizer>();
|
||||||
builder.Services.AddSingleton<IGoogleTokenValidator, GoogleTokenValidator>();
|
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 =
|
private static readonly (string Tag, Regex Pattern, int Weight)[] Patterns =
|
||||||
{
|
{
|
||||||
("C#", new Regex(@"\bC#\b|\bcsharp\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
|
// Symbol skills need punctuation-tolerant boundaries: \b fails next to '#'/'.'
|
||||||
(".NET", new Regex(@"\b\.NET\b|\bASP\.NET\b|\bDOTNET\b|\bEntity Framework\b|\bEF Core\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
|
// (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),
|
("Python", new Regex(@"\bPython\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
|
||||||
("Java", new Regex(@"\bJava\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
|
("Java", new Regex(@"\bJava\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
|
||||||
("JavaScript", new Regex(@"\bJavaScript\b|\bJS\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