Files
jobtrackingapp/JobTrackerApi/Services/JobCvMatchService.cs
cesnimda 67ee3d7274
CI and Deploy / test (pull_request) Successful in 2m6s
CI and Deploy / deploy (pull_request) Has been skipped
feat(ai): prompt-injection delimiters + synonym-aware match scoring
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.
2026-07-11 23:06:52 +02:00

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();
}
}
}