67ee3d7274
Wave 4 hardening. Wrap untrusted CV/job-description/instruction text in tools/summarizer prompts with explicit delimiters and an ignore-embedded-instructions rule, since JD text, recruiter emails, and free-text candidate background all flow into rewrite/normalize prompts unescaped today. Match score previously normalized synonyms (JS/Kubernetes/K8s/etc) only when scanning the job posting, not when checking the CV corpus, so a CV using an abbreviation the job spelled out never matched. SkillTagger.MatchesTag reuses the same synonym regex for both sides.
221 lines
11 KiB
C#
221 lines
11 KiB
C#
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.
|
|
/// IsCuratedTag marks keywords sourced from SkillTagger, whose synonym regex is reused for CV matching.</summary>
|
|
public sealed record MatchKeyword(string Keyword, int Weight, bool InTitle, bool Matched, bool IsCuratedTag = false);
|
|
|
|
/// <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);
|
|
|
|
// Raw (non-normalized) text for curated tags, whose synonym regex needs real word boundaries/punctuation.
|
|
var rawSections = cvSections
|
|
.Where(kvp => !string.IsNullOrWhiteSpace(kvp.Value))
|
|
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.OrdinalIgnoreCase);
|
|
var rawCorpus = string.Join(" \n ", rawSections.Values);
|
|
|
|
var evaluated = keywords
|
|
.Select(k => k with
|
|
{
|
|
Matched = k.IsCuratedTag ? SkillTagger.MatchesTag(k.Keyword, rawCorpus) : 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 => k.IsCuratedTag
|
|
? SkillTagger.MatchesTag(k.Keyword, rawSections.GetValueOrDefault(section.Key))
|
|
: 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, IsCuratedTag: true);
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
}
|
|
}
|