fix(match): prioritize meaningful job terms

This commit is contained in:
cesnimda
2026-08-09 18:47:21 +02:00
parent f2d1963c61
commit da1aa8bb2a
5 changed files with 208 additions and 22 deletions
+103 -8
View File
@@ -1,4 +1,5 @@
using System.Globalization;
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using JobTrackerApi.Services.JobImport;
@@ -40,11 +41,15 @@ namespace JobTrackerApi.Services
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 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", "and", "for", "with", "you", "your", "our", "are", "will", "have", "has", "that",
"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",
@@ -64,6 +69,22 @@ namespace JobTrackerApi.Services
"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)
@@ -134,7 +155,8 @@ namespace JobTrackerApi.Services
private static List<MatchKeyword> BuildKeywords(string jobTitle, string jobText, HashSet<string> titleTokens)
{
var combined = $"{jobTitle}\n{jobText}";
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.
@@ -144,11 +166,22 @@ namespace JobTrackerApi.Services
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))
// 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 (token.Length is < 3 or > 64 || StopWords.Contains(token) || IsNumeric(token)) continue;
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;
}
@@ -174,6 +207,55 @@ namespace JobTrackerApi.Services
.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);
@@ -199,10 +281,23 @@ namespace JobTrackerApi.Services
if (string.IsNullOrWhiteSpace(text)) yield break;
foreach (Match m in TokenPattern.Matches(text.ToLowerInvariant()))
{
yield return m.Value.Trim('-', '.', '+', '#');
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 '+');