316 lines
16 KiB
C#
316 lines
16 KiB
C#
using System.Globalization;
|
|
using System.Net;
|
|
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(@"[\p{L}\p{N}][\p{L}\p{N}+.#/-]*", RegexOptions.Compiled);
|
|
private static readonly Regex HtmlBlockPattern = new(@"<(script|style|nav|header|footer)[^>]*>.*?</\1>", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
|
|
private static readonly Regex HtmlTagPattern = new(@"<[^>]+>", RegexOptions.Compiled);
|
|
private static readonly Regex SegmentPattern = new(@"[\r\n,;:!?\u2022]+|(?<=[.!?])\s+", RegexOptions.Compiled);
|
|
|
|
private static readonly HashSet<string> StopWords = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"the", "a", "an", "and", "or", "of", "to", "in", "on", "as", "is", "be", "if", "it",
|
|
"we", "us", "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",
|
|
// Norwegian function words and generic recruitment language. These are deliberately
|
|
// language-wide categories rather than the handful of examples that exposed the bug.
|
|
"og", "i", "det", "at", "en", "et", "den", "til", "er", "som", "på", "de", "med",
|
|
"av", "ikke", "der", "så", "var", "seg", "men", "har", "om", "vi", "ha", "hadde",
|
|
"hun", "han", "nå", "da", "ved", "fra", "du", "ut", "sin", "dem", "oss", "opp",
|
|
"man", "kan", "hans", "hvor", "eller", "hva", "skal", "selv", "her", "alle", "vil",
|
|
"bli", "ble", "blitt", "kunne", "inn", "når", "være", "noen", "noe", "ville", "dere",
|
|
"deres", "kun", "etter", "ned", "skulle", "denne", "disse", "for", "deg", "sine", "sitt",
|
|
"mot", "uten", "hvordan", "ingen", "din", "ditt", "blir", "samme", "hvilken", "hvilke",
|
|
"erfaring", "erfaringer", "kvalifikasjoner", "arbeidsoppgaver", "stilling", "stillingen",
|
|
"søker", "ser", "ønsker", "mulighet", "spennende", "arbeidsmiljø", "selskap", "bedrift", "kandidat",
|
|
"relevant", "fordel", "gode", "dyktig", "sammen",
|
|
// Common source-page chrome and consent text must never become tailoring advice.
|
|
"cookie", "cookies", "privacy", "terms", "conditions", "menu", "home", "login", "contact",
|
|
"website", "settings", "navigation", "jobs", "apply", "application", "share", "save", "accept",
|
|
"reject", "consent", "exciting", "passionate", "dynamic", "innovative", "motivated",
|
|
};
|
|
|
|
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 cleanedJobText = CleanSourceText(jobText);
|
|
var combined = $"{jobTitle}\n{cleanedJobText}";
|
|
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) Important multi-word terms. Stop words break phrases, so "erfaring med ASP.NET
|
|
// Core" keeps the technology but never emits "erfaring" as advice.
|
|
var phraseTokens = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var phrase in ExtractPhrases(cleanedJobText).Take(8))
|
|
{
|
|
if (byKey.ContainsKey(phrase)) continue;
|
|
var inTitle = TitleContains(jobTitle, phrase);
|
|
byKey[phrase] = new MatchKeyword(phrase, 2 + (inTitle ? TitleBonus : 0), inTitle, false);
|
|
foreach (var token in Tokenize(phrase)) phraseTokens.Add(token);
|
|
}
|
|
|
|
// 3) Salient posting terms: frequency-ranked content words from the cleaned description.
|
|
var frequencies = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var token in Tokenize(cleanedJobText))
|
|
{
|
|
if (token.Length is < 3 or > 64 || StopWords.Contains(token) || phraseTokens.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 IEnumerable<string> ExtractPhrases(string text)
|
|
{
|
|
var candidates = new Dictionary<string, (string Display, int Count, int First)>(StringComparer.OrdinalIgnoreCase);
|
|
var order = 0;
|
|
|
|
foreach (var segment in SegmentPattern.Split(text))
|
|
{
|
|
var run = new List<string>();
|
|
foreach (Match match in TokenPattern.Matches(segment))
|
|
{
|
|
var display = TrimToken(match.Value);
|
|
var normalized = display.ToLowerInvariant();
|
|
if (display.Length == 0 || StopWords.Contains(normalized) || IsNumeric(normalized))
|
|
{
|
|
AddRun(run);
|
|
run.Clear();
|
|
}
|
|
else
|
|
{
|
|
run.Add(display);
|
|
}
|
|
}
|
|
AddRun(run);
|
|
}
|
|
|
|
return candidates.Values
|
|
.OrderByDescending(candidate => candidate.Count)
|
|
.ThenByDescending(candidate => Tokenize(candidate.Display).Count())
|
|
.ThenBy(candidate => candidate.First)
|
|
.Select(candidate => candidate.Display);
|
|
|
|
void AddRun(List<string> run)
|
|
{
|
|
if (run.Count < 2) return;
|
|
AddCandidate(run.Count <= 4 ? run : run.Take(4).ToList());
|
|
if (run.Count > 4) AddCandidate(run.TakeLast(4).ToList());
|
|
}
|
|
|
|
void AddCandidate(List<string> selected)
|
|
{
|
|
var display = string.Join(" ", selected);
|
|
var key = display.ToLowerInvariant();
|
|
if (candidates.TryGetValue(key, out var existing))
|
|
candidates[key] = (existing.Display, existing.Count + 1, existing.First);
|
|
else
|
|
candidates[key] = (display, 1, order++);
|
|
}
|
|
}
|
|
|
|
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()))
|
|
{
|
|
var token = TrimToken(m.Value);
|
|
if (token.Length > 0) yield return token;
|
|
}
|
|
}
|
|
|
|
private static string TrimToken(string token) => token.Trim('-', '.', '/');
|
|
|
|
private static string CleanSourceText(string text)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(text)) return string.Empty;
|
|
var withoutBlocks = HtmlBlockPattern.Replace(text, " ");
|
|
var withoutTags = HtmlTagPattern.Replace(withoutBlocks, "\n");
|
|
var decoded = WebUtility.HtmlDecode(withoutTags).Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n');
|
|
var withoutHorizontalRuns = Regex.Replace(decoded, @"[^\S\r\n]+", " ");
|
|
return Regex.Replace(withoutHorizontalRuns, @"\n{2,}", "\n").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();
|
|
}
|
|
}
|
|
}
|