using System.Globalization; using System.Text; using System.Text.RegularExpressions; using JobTrackerApi.Services.JobImport; namespace JobTrackerApi.Services { /// One keyword drawn from the job posting and whether the CV covers it. public sealed record MatchKeyword(string Keyword, int Weight, bool InTitle, bool Matched); /// How many of the matched keywords appear in a given CV section. public sealed record MatchSectionCoverage(string Section, int Matched, int Total); public sealed record JobCvMatchResult( int Score, string Band, int MatchedCount, int TotalKeywords, IReadOnlyList MatchedKeywords, IReadOnlyList MissingKeywords, IReadOnlyList SectionCoverage, bool HasEnoughSignal); public interface IJobCvMatchService { JobCvMatchResult Evaluate(string jobTitle, string jobText, IReadOnlyDictionary cvSections); } /// /// 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. /// 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 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 cvSections) { jobTitle ??= string.Empty; jobText ??= string.Empty; cvSections ??= new Dictionary(); 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 BuildKeywords(string jobTitle, string jobText, HashSet titleTokens) { var combined = $"{jobTitle}\n{jobText}"; var byKey = new Dictionary(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(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 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(); } } }