feat: complete release readiness work #28

Open
cesnimda wants to merge 110 commits from release-readiness into main
5 changed files with 208 additions and 22 deletions
Showing only changes of commit da1aa8bb2a - Show all commits
@@ -10,6 +10,84 @@ public sealed class JobCvMatchServiceTests
private static Dictionary<string, string> Sections(params (string Name, string Text)[] items)
=> items.ToDictionary(i => i.Name, i => i.Text, StringComparer.OrdinalIgnoreCase);
public static IEnumerable<object[]> QualityFixtures()
{
yield return new object[]
{
"Norwegian",
"Senior systemutvikler",
"Vi ser etter deg som har erfaring med C# og ASP.NET Core. Du vil designe skalerbare distribuerte systemer. Gode samarbeidsevner er en fordel.",
new[] { "C#", "ASP.NET Core", "designe skalerbare distribuerte systemer" },
new[] { "med", "til", "for", "som", "erfaring", "ser" },
};
yield return new object[]
{
"English",
"Cloud platform engineer",
"We need a candidate with experience in AWS and Terraform. You will lead incident response and operate distributed systems.",
new[] { "AWS", "Terraform", "incident response", "operate distributed systems" },
new[] { "the", "with", "experience", "candidate", "will" },
};
yield return new object[]
{
"Mixed Norwegian and English",
"DevOps-utvikler",
"Du vil jobbe med Node.js og CI/CD. Work closely with Azure DevOps and cross-functional product teams.",
new[] { "Node.js", "CI/CD", "Azure DevOps", "Collaboration" },
new[] { "og", "med", "with", "teams" },
};
yield return new object[]
{
"Short",
"Data developer",
"Python and SQL.",
new[] { "Python", "SQL" },
new[] { "and" },
};
yield return new object[]
{
"Noisy HTML",
"Frontend developer",
"<nav>Home Jobs Login</nav><script>trackingCookie('React')</script><main>Build accessible web applications with React and TypeScript.</main><footer>Cookie settings Privacy Terms</footer>",
new[] { "React", "TypeScript", "Build accessible web applications" },
new[] { "home", "jobs", "login", "cookie", "settings", "privacy", "terms", "trackingcookie" },
};
yield return new object[]
{
"Technology heavy",
"Platform developer",
"C++, C#, .NET, ASP.NET Core, Node.js, CI/CD, Azure DevOps and Kubernetes.",
new[] { "C++", "C#", ".NET", "ASP.NET Core", "Node.js", "CI/CD", "Azure DevOps", "Kubernetes" },
new[] { "and" },
};
yield return new object[]
{
"Repeated recruitment filler",
"Software engineer",
"Exciting opportunity for a passionate candidate. Great opportunity, strong experience required. We offer an exciting dynamic environment. Apply now. Build services using domain-driven design and Docker.",
new[] { "domain-driven design", "Docker" },
new[] { "exciting", "opportunity", "passionate", "candidate", "experience", "environment", "apply" },
};
}
[Theory]
[MemberData(nameof(QualityFixtures))]
public void Quality_fixtures_keep_useful_terms_and_suppress_noise(
string name,
string title,
string description,
string[] expected,
string[] excluded)
{
var result = _service.Evaluate(title, description, Sections(("Skills", "synthetic profile text")));
var terms = result.MatchedKeywords.Concat(result.MissingKeywords).ToList();
foreach (var term in expected)
Assert.True(terms.Contains(term, StringComparer.OrdinalIgnoreCase), $"{name}: expected '{term}' in [{string.Join(", ", terms)}]");
foreach (var term in excluded)
Assert.False(terms.Contains(term, StringComparer.OrdinalIgnoreCase), $"{name}: did not expect '{term}' in [{string.Join(", ", terms)}]");
}
[Fact]
public void Strong_overlap_scores_high_and_lists_matched_keywords()
{
+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 '+');
@@ -12,18 +12,28 @@ public static class SkillTagger
// Symbol skills need punctuation-tolerant boundaries: \b fails next to '#'/'.'
// (both non-word chars), which previously left "C#," and ".NET," undetected.
("C#", new Regex(@"(?<![A-Za-z0-9#])C#|\bc[-\s]?sharp\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
(".NET", new Regex(@"(?<![A-Za-z0-9.])\.NET\b|\bASP\.NET\b|\bDOTNET\b|\bEntity Framework\b|\bEF Core\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
("C++", new Regex(@"(?<![A-Za-z0-9+])C\+\+(?!\+)|\bcpp\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
("ASP.NET Core", new Regex(@"\bASP\.NET\s+Core\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
("Entity Framework Core", new Regex(@"\bEntity Framework(?: Core)?\b|\bEF Core\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
(".NET", new Regex(@"(?<![A-Za-z0-9.])\.NET\b|\bDOTNET\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
("Python", new Regex(@"\bPython\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
("Java", new Regex(@"\bJava\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
("Go", new Regex(@"\bGolang\b|\bGo\s+(?:programming|language|developer|engineer)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
("JavaScript", new Regex(@"\bJavaScript\b|\bJS\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
("TypeScript", new Regex(@"\bTypeScript\b|\bTS\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
("React", new Regex(@"\bReact\b|\bReact\.js\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
("Node.js", new Regex(@"\bNode\b|\bNode\.js\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
("Next.js", new Regex(@"\bNext\.js\b|\bNextJS\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
("SQL", new Regex(@"\bSQL\b|\bPostgreSQL\b|\bMySQL\b|\bSQLite\b|\bMS\s*SQL\b|\bT-?SQL\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
("Docker", new Regex(@"\bDocker\b|\bcontainers?\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
("Kubernetes", new Regex(@"\bKubernetes\b|\bK8s\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
("Azure", new Regex(@"\bAzure\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
("Terraform", new Regex(@"\bTerraform\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
("Azure DevOps", new Regex(@"\bAzure\s+DevOps\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
("Azure", new Regex(@"\bAzure\b(?!\s+DevOps)", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
("AWS", new Regex(@"\bAWS\b|\bAmazon Web Services\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
("Kafka", new Regex(@"\b(?:Apache\s+)?Kafka\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
("Redis", new Regex(@"\bRedis\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
("Linux", new Regex(@"\bLinux\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
("CI/CD", new Regex(@"\bCI/CD\b|continuous integration|continuous delivery|continuous deployment", RegexOptions.IgnoreCase | RegexOptions.Compiled), 4),
("REST APIs", new Regex(@"\bREST\b|RESTful|API development|web services", RegexOptions.IgnoreCase | RegexOptions.Compiled), 4),
("GraphQL", new Regex(@"\bGraphQL\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 4),
+10 -10
View File
@@ -1065,13 +1065,13 @@ export const translations = {
matchScoreBand_Partial: "Partial match",
matchScoreBand_Low: "Low match",
matchScoreBand_Unknown: "Not enough signal",
matchScoreKeywordsCovered: "{matched}/{total} keywords",
matchScoreKeywordsCovered: "{matched}/{total} important terms",
matchScoreNoSignal: "Add more CV detail or a fuller job description to get a reliable score.",
matchScoreDeterministicHint: "Deterministic keyword coverage — no AI, so the score is stable and repeatable. For a written opinion on strengths and gaps, see the AI section below.",
matchScoreMatched: "Matched keywords",
matchScoreMissing: "Missing keywords",
matchScoreDeterministicHint: "Deterministic important-term coverage — no AI, so the score is stable and repeatable. For a written opinion on strengths and gaps, see the AI section below.",
matchScoreMatched: "Important terms already in your CV",
matchScoreMissing: "Important terms from the job to review",
matchScoreNoneYet: "No matches found yet.",
matchScoreAllCovered: "Every keyword is covered.",
matchScoreAllCovered: "Every important term is covered.",
matchScoreSectionCoverage: "Where your CV covers this role",
matchScoreLearningPath: "Learning path",
matchScoreLearningPathHint: "Job-specific skill gaps from the deterministic match. Verify real evidence before adding a skill to your profile.",
@@ -2195,13 +2195,13 @@ export const translations = {
matchScoreBand_Partial: "Delvis match",
matchScoreBand_Low: "Lav match",
matchScoreBand_Unknown: "For lite grunnlag",
matchScoreKeywordsCovered: "{matched}/{total} nøkkelord",
matchScoreKeywordsCovered: "{matched}/{total} viktige begreper",
matchScoreNoSignal: "Legg til mer CV-innhold eller en fyldigere stillingstekst for en pålitelig score.",
matchScoreDeterministicHint: "Deterministisk nøkkelorddekning — ingen AI, så scoren er stabil og repeterbar. For en skriftlig vurdering av styrker og svakheter, se AI-seksjonen under.",
matchScoreMatched: "Treff på nøkkelord",
matchScoreMissing: "Manglende nøkkelord",
matchScoreDeterministicHint: "Deterministisk dekning av viktige begreper — ingen AI, så scoren er stabil og repeterbar. For en skriftlig vurdering av styrker og svakheter, se AI-seksjonen under.",
matchScoreMatched: "Viktige begreper i CV-en",
matchScoreMissing: "Viktige begreper fra stillingen å vurdere",
matchScoreNoneYet: "Ingen treff ennå.",
matchScoreAllCovered: "Alle nøkkelord er dekket.",
matchScoreAllCovered: "Alle viktige begreper er dekket.",
matchScoreSectionCoverage: "Hvor CV-en dekker denne rollen",
matchScoreLearningPath: "Læringssti",
matchScoreLearningPathHint: "Jobbspesifikke ferdighetsgap fra den deterministiske matchen. Bekreft reell erfaring før du legger en ferdighet til profilen.",
@@ -76,12 +76,15 @@ afterEach(() => {
jest.clearAllMocks();
});
test('match score panel shows the score, matched and missing keywords', async () => {
test('match score panel shows the score and honest important-term labels', async () => {
renderDialog();
expect(await screen.findByText('82%')).toBeInTheDocument();
expect(await screen.findByText(/strong match/i)).toBeInTheDocument();
expect(await screen.findByText('4/6 keywords')).toBeInTheDocument();
expect(await screen.findByText('4/6 important terms')).toBeInTheDocument();
expect(await screen.findByText('Important terms already in your CV')).toBeInTheDocument();
expect(await screen.findByText('Important terms from the job to review')).toBeInTheDocument();
expect(screen.queryByText(/matched keywords/i)).not.toBeInTheDocument();
// Matched keyword chips
expect(await screen.findByText('C#')).toBeInTheDocument();