@pranavraut033/ats-checker 1.2.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,209 +1,143 @@
1
- // src/utils/text.ts
2
- var STOP_WORDS = /* @__PURE__ */ new Set([
3
- // articles / prepositions / conjunctions
4
- "the",
5
- "and",
6
- "or",
7
- "a",
8
- "an",
9
- "of",
10
- "for",
11
- "to",
12
- "with",
13
- "in",
14
- "on",
15
- "at",
16
- "by",
17
- "from",
18
- "as",
19
- "into",
20
- "onto",
21
- "upon",
22
- "via",
23
- "per",
24
- "plus",
25
- // verbs / modals
26
- "is",
27
- "are",
28
- "be",
29
- "was",
30
- "were",
31
- "will",
32
- "can",
33
- "should",
34
- "must",
35
- "have",
36
- "has",
37
- "had",
38
- "do",
39
- "does",
40
- "did",
41
- "get",
42
- "give",
43
- "go",
44
- "use",
45
- "see",
46
- "help",
47
- "work",
48
- "build",
49
- "show",
50
- "need",
51
- "want",
52
- "make",
53
- "let",
54
- // pronouns / determiners
55
- "it",
56
- "its",
57
- "this",
58
- "that",
59
- "these",
60
- "those",
61
- "we",
62
- "our",
63
- "you",
64
- "your",
65
- "they",
66
- "their",
67
- "us",
68
- "who",
69
- "what",
70
- "which",
71
- "how",
72
- // common English fillers that leak into JDs
73
- "no",
74
- "not",
75
- "all",
76
- "any",
77
- "also",
78
- "more",
79
- "well",
80
- "very",
81
- "highly",
82
- "across",
83
- "over",
84
- "under",
85
- "within",
86
- "about",
87
- "out",
88
- "up",
89
- "down",
90
- "new",
91
- "if",
92
- "so",
93
- "such",
94
- "both",
95
- "each",
96
- "one",
97
- "many",
98
- "only",
99
- // JD/HR boilerplate — never skills
100
- "years",
101
- "year",
102
- "experience",
103
- "required",
104
- "requirement",
105
- "requirements",
106
- "preferred",
107
- "role",
108
- "degree",
109
- "practices",
110
- "best",
111
- "skills",
112
- "team",
113
- "field",
114
- "related",
115
- "relevant",
116
- "desired",
117
- "strong",
118
- "solid",
119
- "good",
120
- "first",
121
- "based",
122
- "day",
123
- "week",
124
- "month",
125
- "time",
126
- "fast",
127
- "open",
128
- "dynamic"
129
- ]);
130
- function normalizeWhitespace(text) {
131
- return text.replace(/\r\n?/g, "\n").replace(/\s+/g, " ").trim();
132
- }
133
- function normalizeForComparison(text) {
134
- return normalizeWhitespace(text).normalize("NFKC").toLowerCase();
135
- }
136
- function splitLines(text) {
137
- return text.replace(/\r\n?/g, "\n").split("\n").map((line) => line.trim()).filter(Boolean);
138
- }
139
- var TECH_TOKEN_RE = /[a-z0-9][a-z0-9.#+\-/]*[a-z0-9#+]/g;
140
- function tokenize(text) {
141
- const normalized = normalizeForComparison(text);
142
- return (normalized.match(TECH_TOKEN_RE) ?? []).filter(
143
- (t) => /[a-z]/.test(t) && !STOP_WORDS.has(t)
144
- );
1
+ import { clamp, mergeKeywordRegistries, defaultKeywordRegistry, softwareEngineerProfile, buildCategoryIndex, deriveSkillAliases, normalizeWhitespace, splitLines, normalizeSkills, tokenize, unique, containsTableLikeStructure, normalizeForComparison, STOP_WORDS, normalizeSkill, countFrequencies, escapeRegExp } from './chunk-ZJ5E4H7Z.mjs';
2
+ export { defaultKeywordRegistry, defaultProfiles, defaultSkillAliases } from './chunk-ZJ5E4H7Z.mjs';
3
+
4
+ // src/utils/languages.ts
5
+ var KNOWN_LANGUAGES = [
6
+ "english",
7
+ "spanish",
8
+ "french",
9
+ "german",
10
+ "italian",
11
+ "portuguese",
12
+ "dutch",
13
+ "russian",
14
+ "mandarin",
15
+ "chinese",
16
+ "cantonese",
17
+ "japanese",
18
+ "korean",
19
+ "arabic",
20
+ "hindi",
21
+ "polish",
22
+ "turkish",
23
+ "vietnamese",
24
+ "swedish",
25
+ "norwegian",
26
+ "danish",
27
+ "finnish",
28
+ "greek",
29
+ "hebrew",
30
+ "thai",
31
+ "indonesian",
32
+ "ukrainian",
33
+ "czech"
34
+ ];
35
+ var LANGUAGE_ALIASES = {
36
+ mandarin: "chinese",
37
+ cantonese: "chinese"
38
+ };
39
+ var LEVEL_RANK = {
40
+ a1: 1,
41
+ a2: 2,
42
+ b1: 3,
43
+ b2: 4,
44
+ c1: 5,
45
+ c2: 6,
46
+ basic: 1,
47
+ elementary: 1,
48
+ limited: 2,
49
+ conversational: 3,
50
+ intermediate: 3,
51
+ professional: 4,
52
+ "upper intermediate": 4,
53
+ advanced: 4,
54
+ fluent: 5,
55
+ native: 6,
56
+ "native speaker": 6,
57
+ bilingual: 6,
58
+ // German
59
+ grundkenntnisse: 1,
60
+ gering: 2,
61
+ gut: 3,
62
+ fortgeschritten: 4,
63
+ flie\u00DFend: 5,
64
+ muttersprache: 6,
65
+ muttersprachler: 6,
66
+ // French
67
+ "d\xE9butant": 1,
68
+ "\xE9l\xE9mentaire": 1,
69
+ "limit\xE9": 2,
70
+ "interm\xE9diaire": 3,
71
+ "avanc\xE9": 4,
72
+ courant: 5,
73
+ natif: 6,
74
+ "langue maternelle": 6,
75
+ bilingue: 6
76
+ };
77
+ var LANGUAGE_GROUP = KNOWN_LANGUAGES.join("|");
78
+ var LEVEL_GROUP = Object.keys(LEVEL_RANK).sort((a, b) => b.length - a.length).map((l) => l.replace(/\s+/g, "\\s+")).join("|");
79
+ var BOUNDARY_START = "(?:^|(?<=[^a-z\xE0-\xFF]))";
80
+ var BOUNDARY_END = "(?:$|(?=[^a-z\xE0-\xFF]))";
81
+ var LANGUAGE_LEVEL_RE = new RegExp(
82
+ `\\b(${LANGUAGE_GROUP})\\b(?:\\s*[\\(:\\-]?\\s*(${BOUNDARY_START}(?:${LEVEL_GROUP})${BOUNDARY_END}|[abc][12]))?`,
83
+ "gi"
84
+ );
85
+ var LEVEL_BEFORE_LANGUAGE_RE = new RegExp(
86
+ `${BOUNDARY_START}(${LEVEL_GROUP})${BOUNDARY_END}\\s+(?:in\\s+)?(${LANGUAGE_GROUP})\\b`,
87
+ "gi"
88
+ );
89
+ function canonicalLanguage(name) {
90
+ const lower = name.toLowerCase();
91
+ return LANGUAGE_ALIASES[lower] ?? lower;
145
92
  }
146
- function escapeRegExp(input) {
147
- return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
93
+ function toParsedLanguage(name, level) {
94
+ const normalizedLevel = level?.toLowerCase().replace(/\s+/g, " ");
95
+ return {
96
+ name: canonicalLanguage(name),
97
+ level: normalizedLevel,
98
+ levelRank: normalizedLevel ? LEVEL_RANK[normalizedLevel] : void 0
99
+ };
148
100
  }
149
- function unique(values) {
150
- const seen = /* @__PURE__ */ new Set();
151
- const output = [];
152
- for (const value of values) {
153
- const lower = value.toLowerCase();
154
- if (!seen.has(lower)) {
155
- seen.add(lower);
156
- output.push(value);
101
+ function parseLanguageMentions(text) {
102
+ const found = /* @__PURE__ */ new Map();
103
+ for (const match of text.matchAll(LANGUAGE_LEVEL_RE)) {
104
+ const parsed = toParsedLanguage(match[1], match[2]);
105
+ const existing = found.get(parsed.name);
106
+ if (!existing || (parsed.levelRank ?? 0) > (existing.levelRank ?? 0)) {
107
+ found.set(parsed.name, parsed);
157
108
  }
158
109
  }
159
- return output;
160
- }
161
- function clamp(value, min, max) {
162
- return Math.min(Math.max(value, min), max);
163
- }
164
- function countFrequencies(values) {
165
- const counts = {};
166
- for (const value of values) {
167
- counts[value] = (counts[value] ?? 0) + 1;
168
- }
169
- return counts;
170
- }
171
- function containsTableLikeStructure(text) {
172
- const lines = splitLines(text);
173
- let tableLines = 0;
174
- for (const line of lines) {
175
- const hasPipeColumns = line.includes("|") && line.split("|").length >= 3;
176
- const hasTabColumns = /\t.+\t/.test(line);
177
- const hasAlignedSpaces = /( {3,})(\S+)( {3,}\S+)/.test(line);
178
- if (hasPipeColumns || hasTabColumns || hasAlignedSpaces) {
179
- tableLines += 1;
110
+ for (const match of text.matchAll(LEVEL_BEFORE_LANGUAGE_RE)) {
111
+ const parsed = toParsedLanguage(match[2], match[1]);
112
+ const existing = found.get(parsed.name);
113
+ if (!existing || (parsed.levelRank ?? 0) > (existing.levelRank ?? 0)) {
114
+ found.set(parsed.name, parsed);
180
115
  }
181
116
  }
182
- return tableLines >= 2;
117
+ return [...found.values()].sort((a, b) => a.name.localeCompare(b.name));
183
118
  }
184
-
185
- // src/utils/skills.ts
186
- function normalizeSkill(skill, aliases) {
187
- const normalized = skill.trim().toLowerCase();
188
- for (const [canonical, aliasList] of Object.entries(aliases)) {
189
- if (canonical.toLowerCase() === normalized) {
190
- return canonical.toLowerCase();
191
- }
192
- if (aliasList.some((alias) => alias.toLowerCase() === normalized)) {
193
- return canonical.toLowerCase();
119
+ function diffLanguages(resumeLanguages, requiredLanguages) {
120
+ const byName = new Map(resumeLanguages.map((l) => [l.name, l]));
121
+ const matched = [];
122
+ const missing = [];
123
+ for (const required of requiredLanguages) {
124
+ const have = byName.get(required.name);
125
+ const requiredRank = required.levelRank ?? 0;
126
+ const haveRank = have?.levelRank ?? 0;
127
+ if (have && haveRank >= requiredRank) {
128
+ matched.push(required);
129
+ } else {
130
+ missing.push(required);
194
131
  }
195
132
  }
196
- return normalized;
197
- }
198
- function normalizeSkills(skills, aliases) {
199
- return unique(skills.map((skill) => normalizeSkill(skill, aliases)));
133
+ return { matched, missing };
200
134
  }
201
135
 
202
136
  // src/core/parser/jd.parser.ts
203
137
  var DEGREE_VARIANTS = [
204
- [/\b(?:bachelor(?:'s)?|b\.s\.?|bs\.?|bsc\.?)\b/i, "bachelor"],
205
- [/\b(?:master(?:'s)?|m\.s\.?|ms\.?|msc\.?)\b/i, "master"],
206
- [/\b(?:phd|ph\.d\.?|doctorate)\b/i, "phd"],
138
+ [/\b(?:bachelor(?:'s)?|b\.s\.?|bs\.?|bsc\.?|licence)\b/i, "bachelor"],
139
+ [/\b(?:master(?:'s)?|m\.s\.?|ms\.?|msc\.?|diplom)\b/i, "master"],
140
+ [/\b(?:phd|ph\.d\.?|doctorate|doktor|doctorat)\b/i, "phd"],
207
141
  [/\bmba\b/i, "mba"],
208
142
  [/\bassociate(?:'s)?\b/i, "associate"]
209
143
  ];
@@ -226,18 +160,38 @@ function extractPreferredSkills(lines) {
226
160
  return preferred;
227
161
  }
228
162
  function extractRoleKeywords(text) {
229
- const roleMatch = text.match(/(engineer|developer|manager|scientist|analyst|designer|architect)/i);
230
- const titleTokens = roleMatch ? roleMatch[0].split(/\s+/) : [];
231
- return unique(tokenize(titleTokens.join(" ") || text.split(/\n/)[0] || ""));
163
+ const roleMatches = text.match(/(engineer|developer|manager|scientist|analyst|designer|architect|director|consultant|lead|vp)/gi) ?? [];
164
+ const fallback = roleMatches.length === 0 ? [text.split(/\n/)[0] ?? ""] : [];
165
+ return unique(tokenize([...roleMatches, ...fallback].join(" ")));
232
166
  }
233
167
  function extractMinExperience(text) {
234
- const match = text.match(/(\d{1,2})\+?\s+(?:years|yrs)/i);
235
- if (match) {
236
- return Number.parseInt(match[1], 10);
168
+ const match = text.match(/(\d{1,2})\+?\s*(?:years?|yrs\.?|jahre?|ans?|années?)/i);
169
+ if (!match) return void 0;
170
+ const parsed = Number.parseInt(match[1], 10);
171
+ return parsed <= 60 ? parsed : void 0;
172
+ }
173
+ var SURFACE_TOKEN_RE = /[a-z0-9][a-z0-9.#+\-/]*[a-z0-9#+]/gi;
174
+ function collectKeywordSurfaceForms(rawText, aliases) {
175
+ const matches = rawText.match(SURFACE_TOKEN_RE) ?? [];
176
+ const surfaceForms = {};
177
+ for (const match of matches) {
178
+ const canonical = normalizeSkill(match, aliases);
179
+ if (!(canonical in surfaceForms)) {
180
+ surfaceForms[canonical] = match;
181
+ }
237
182
  }
238
- return void 0;
183
+ return surfaceForms;
239
184
  }
240
- function extractEducationRequirements(text) {
185
+ var LANG_SECTION_RE = /^\s*(?:languages?|sprache|langue)s?\s*[:\-–—]?\s*/i;
186
+ var LANG_REQUIREMENT_HINT_RE = /\b(fluent|required|must|need|speak|proficient|native|conversational|intermediate|advanced|professional|[abc][12])\b/i;
187
+ function isLanguageRequired(lang, jobDescription) {
188
+ return splitLines(jobDescription).some((line) => {
189
+ const lower = line.toLowerCase();
190
+ if (!lower.includes(lang.name)) return false;
191
+ return LANG_SECTION_RE.test(line) || LANG_REQUIREMENT_HINT_RE.test(line);
192
+ });
193
+ }
194
+ function extractDegreeLevels(text) {
241
195
  const found = /* @__PURE__ */ new Set();
242
196
  for (const [pattern, canonical] of DEGREE_VARIANTS) {
243
197
  if (pattern.test(text)) found.add(canonical);
@@ -275,7 +229,13 @@ function parseJobDescription(jobDescription, config) {
275
229
  roleKeywords,
276
230
  keywords,
277
231
  minExperienceYears: extractMinExperience(jobDescription),
278
- educationRequirements: extractEducationRequirements(jobDescription)
232
+ educationRequirements: extractDegreeLevels(jobDescription),
233
+ keywordSurfaceForms: collectKeywordSurfaceForms(jobDescription, config.skillAliases),
234
+ // A language only counts as required if its mention carries a requirement/level cue
235
+ // or sits in a "Languages:" line — plain references ("our Berlin office") don't count.
236
+ requiredLanguages: parseLanguageMentions(jobDescription).filter(
237
+ (lang) => isLanguageRequired(lang, jobDescription)
238
+ )
279
239
  };
280
240
  }
281
241
 
@@ -304,11 +264,37 @@ var MONTHS = {
304
264
  nov: 11,
305
265
  november: 11,
306
266
  dec: 12,
307
- december: 12
267
+ december: 12,
268
+ // German
269
+ januar: 1,
270
+ j\u00E4nner: 1,
271
+ februar: 2,
272
+ m\u00E4rz: 3,
273
+ maerz: 3,
274
+ mai: 5,
275
+ juni: 6,
276
+ juli: 7,
277
+ oktober: 10,
278
+ dezember: 12,
279
+ // French
280
+ janvier: 1,
281
+ f\u00E9vrier: 2,
282
+ fevrier: 2,
283
+ mars: 3,
284
+ avril: 4,
285
+ juin: 6,
286
+ juillet: 7,
287
+ ao\u00FBt: 8,
288
+ aout: 8,
289
+ septembre: 9,
290
+ octobre: 10,
291
+ novembre: 11,
292
+ d\u00E9cembre: 12,
293
+ decembre: 12
308
294
  };
309
295
  function parseDateToken(raw) {
310
296
  const cleaned = raw.trim().toLowerCase();
311
- const monthMatch = cleaned.match(/([a-z]{3,9})\s*(\d{4})/i);
297
+ const monthMatch = cleaned.match(/([a-zà-ÿ]{3,9})\s*(\d{4})/i);
312
298
  if (monthMatch) {
313
299
  const monthName = monthMatch[1].toLowerCase();
314
300
  const year = Number.parseInt(monthMatch[2], 10);
@@ -340,14 +326,14 @@ function monthsBetween(start, end) {
340
326
  function parseDateRange(text, referenceDate) {
341
327
  const normalized = text.trim();
342
328
  const rangeMatch = normalized.match(
343
- /(\d{1,2}\/\d{4}|[A-Za-z]{3,9}\s+\d{4}|\d{4})\s*(?:-|to|–|—)\s*(Present|Current|Now|\d{1,2}\/\d{4}|[A-Za-z]{3,9}\s+\d{4}|\d{4})/i
329
+ /(\d{1,2}\/\d{4}|[A-Za-zà-ÿ]{3,9}\s+\d{4}|\d{4})\s*(?:-|to|through|until|bis|jusqu'à|à|–|—)\s*(Present|Current|Now|Aktuell|Heute|Actuellement|Présent|\d{1,2}\/\d{4}|[A-Za-zà-ÿ]{3,9}\s+\d{4}|\d{4})/i
344
330
  );
345
331
  if (!rangeMatch) {
346
332
  return null;
347
333
  }
348
334
  const startToken = parseDateToken(rangeMatch[1]);
349
335
  const endRaw = rangeMatch[2];
350
- const isPresent = /present|current|now/i.test(endRaw);
336
+ const isPresent = /present|current|now|aktuell|heute|actuellement|présent|actuel/i.test(endRaw);
351
337
  const endToken = isPresent ? void 0 : parseDateToken(endRaw);
352
338
  if (!startToken) {
353
339
  return null;
@@ -401,14 +387,23 @@ function sumExperienceYears(ranges) {
401
387
 
402
388
  // src/core/parser/resume.parser.ts
403
389
  var SECTION_ALIASES = {
404
- summary: ["summary", "profile", "about"],
405
- experience: ["experience", "work experience", "professional experience", "employment"],
406
- skills: ["skills", "technical skills", "technologies"],
407
- education: ["education", "academics", "academic background"],
408
- projects: ["projects", "portfolio"],
409
- certifications: ["certifications", "licenses"]
390
+ summary: ["summary", "profile", "about", "zusammenfassung", "profil", "r\xE9sum\xE9", "\xE0 propos"],
391
+ experience: [
392
+ "experience",
393
+ "work experience",
394
+ "professional experience",
395
+ "employment",
396
+ "erfahrung",
397
+ "berufserfahrung",
398
+ "exp\xE9rience",
399
+ "exp\xE9rience professionnelle"
400
+ ],
401
+ skills: ["skills", "technical skills", "technologies", "f\xE4higkeiten", "kenntnisse", "comp\xE9tences"],
402
+ education: ["education", "academics", "academic background", "ausbildung", "formation", "\xE9tudes"],
403
+ projects: ["projects", "portfolio", "projekte", "projets"],
404
+ certifications: ["certifications", "licenses", "zertifizierungen", "certifications professionnelles"]
410
405
  };
411
- var ACTION_VERBS = [
406
+ var STRONG_VERBS = [
412
407
  "led",
413
408
  "managed",
414
409
  "built",
@@ -429,6 +424,21 @@ var ACTION_VERBS = [
429
424
  "reduced",
430
425
  "increased"
431
426
  ];
427
+ var WEAK_VERBS = ["worked", "helped", "performed", "responsible", "assisted", "participated", "involved"];
428
+ var METRIC_RE = /\d|%|\$|\bk\+|\bm\+/i;
429
+ function classifyAchievement(line) {
430
+ const lower = line.toLowerCase();
431
+ const hasMetric = METRIC_RE.test(line);
432
+ const hasStrongVerb = STRONG_VERBS.some((verb) => new RegExp(`\\b${verb}\\b`).test(lower));
433
+ const hasWeakVerb = WEAK_VERBS.some((verb) => new RegExp(`\\b${verb}\\b`).test(lower));
434
+ if (hasStrongVerb && hasMetric) {
435
+ return { text: line, strength: "strong", reason: "strong verb + quantified impact" };
436
+ }
437
+ if (hasWeakVerb) {
438
+ return { text: line, strength: "weak", reason: "weak verb" };
439
+ }
440
+ return { text: line, strength: "weak", reason: "no quantified impact" };
441
+ }
432
442
  function detectSection(line) {
433
443
  const normalized = line.trim().toLowerCase();
434
444
  for (const [section, aliases] of Object.entries(SECTION_ALIASES)) {
@@ -469,21 +479,27 @@ function extractSections(text) {
469
479
  }
470
480
  function parseSkills(sectionContent, aliases) {
471
481
  if (!sectionContent) return [];
472
- const raw = sectionContent.split(/[,;\n]/).map((skill) => skill.trim()).filter(Boolean);
482
+ const hasBullets = /[•·‣▪○●◦]/.test(sectionContent);
483
+ const normalized = hasBullets ? sectionContent.replace(/\n/g, " ") : sectionContent;
484
+ const raw = normalized.split(/[,;\n]|[•·‣▪○●◦]/).map((skill) => skill.trim().replace(/^[-•·‣▪○●◦\s]+|[-•·‣▪○●◦\s]+$/g, "").trim()).filter(Boolean);
473
485
  return normalizeSkills(raw, aliases);
474
486
  }
475
487
  function parseActionVerbs(text) {
476
488
  const words = tokenize(text);
477
- return ACTION_VERBS.filter((verb) => words.includes(verb));
489
+ return {
490
+ strong: STRONG_VERBS.filter((verb) => words.includes(verb)),
491
+ weak: WEAK_VERBS.filter((verb) => words.includes(verb))
492
+ };
478
493
  }
479
494
  function parseExperience(sectionContent, referenceDate) {
480
495
  if (!sectionContent) {
481
- return { entries: [], rangesInMonths: [], jobTitles: [] };
496
+ return { entries: [], rangesInMonths: [], jobTitles: [], achievements: [] };
482
497
  }
483
498
  const lines = splitLines(sectionContent);
484
499
  const entries = [];
485
500
  const rangesInMonths = [];
486
501
  const jobTitles = [];
502
+ const achievements = [];
487
503
  for (const line of lines) {
488
504
  const range = parseDateRange(line, referenceDate);
489
505
  if (range) {
@@ -498,20 +514,22 @@ function parseExperience(sectionContent, referenceDate) {
498
514
  }
499
515
  continue;
500
516
  }
501
- const titleMatch = line.match(/^(Senior|Lead|Principal|Staff|Software|Full\s*Stack|Frontend|Backend|Engineer|Developer|Manager|Analyst)[^,-]*/i);
517
+ const titleMatch = line.match(/^(Senior|Lead|Principal|Staff|VP|Director|Consultant|Architect|Software|Full\s*Stack|Frontend|Backend|Engineer|Developer|Manager|Analyst)[^,-]*/i);
502
518
  if (titleMatch) {
503
519
  const title = titleMatch[0].trim();
504
520
  jobTitles.push(title.toLowerCase());
505
521
  const entry = { title, description: line };
506
522
  entries.push(entry);
523
+ achievements.push(classifyAchievement(line));
507
524
  continue;
508
525
  }
509
526
  if (entries.length > 0) {
510
527
  const current = entries[entries.length - 1];
511
528
  current.description = [current.description, line].filter(Boolean).join(" ").trim();
512
529
  }
530
+ achievements.push(classifyAchievement(line));
513
531
  }
514
- return { entries, rangesInMonths, jobTitles: unique(jobTitles) };
532
+ return { entries, rangesInMonths, jobTitles: unique(jobTitles), achievements };
515
533
  }
516
534
  function parseEducation(sectionContent) {
517
535
  if (!sectionContent) return [];
@@ -534,7 +552,8 @@ function parseResume(resumeText, config) {
534
552
  const textToScan = sections.summary ?? normalizedText;
535
553
  const yearsMatch = textToScan.match(/(\d{1,2})\+?\s*years?/i);
536
554
  if (yearsMatch) {
537
- totalExperienceYears = Number.parseInt(yearsMatch[1], 10);
555
+ const parsed = Number.parseInt(yearsMatch[1], 10);
556
+ totalExperienceYears = parsed <= 60 ? parsed : 0;
538
557
  }
539
558
  }
540
559
  const requiredSections = ["summary", "experience", "skills", "education"];
@@ -561,11 +580,14 @@ function parseResume(resumeText, config) {
561
580
  sectionContent: sections,
562
581
  skills,
563
582
  jobTitles: experienceData.jobTitles,
564
- actionVerbs,
583
+ actionVerbs: actionVerbs.strong,
584
+ weakVerbs: actionVerbs.weak,
585
+ achievements: experienceData.achievements,
565
586
  educationEntries,
566
587
  experience: experienceData.entries,
567
588
  totalExperienceYears,
568
589
  keywords: collectKeywords(normalizedText),
590
+ languages: parseLanguageMentions(resumeText),
569
591
  warnings
570
592
  };
571
593
  }
@@ -647,6 +669,14 @@ var REQUIRED_SKILL_WEIGHT = 0.7;
647
669
  var OPTIONAL_SKILL_WEIGHT = 0.3;
648
670
  var EXPERIENCE_YEARS_WEIGHT = 0.75;
649
671
  var EXPERIENCE_ROLE_WEIGHT = 0.25;
672
+ var ALL_CATEGORIES = ["technical", "tool", "concept", "soft", "marketing", "domain"];
673
+ function emptyCategoryBuckets() {
674
+ const buckets = {};
675
+ for (const category of ALL_CATEGORIES) {
676
+ buckets[category] = { matched: [], missing: [] };
677
+ }
678
+ return buckets;
679
+ }
650
680
  function scoreSkills(resume, job, config) {
651
681
  const profileRequired = config.profile?.mandatorySkills ?? [];
652
682
  const profileOptional = config.profile?.optionalSkills ?? [];
@@ -685,42 +715,83 @@ function scoreExperience(resume, job, config) {
685
715
  const missingYears = Math.max(requiredYears - resume.totalExperienceYears, 0);
686
716
  return { score, missingYears: Number(missingYears.toFixed(2)) };
687
717
  }
718
+ function keywordWeightOf(keyword, requiredSet, preferredSet, jdFrequencies) {
719
+ const base = requiredSet.has(keyword) ? 3 : preferredSet.has(keyword) ? 2 : 1;
720
+ const freqBonus = Math.min((jdFrequencies[keyword] ?? 1) - 1, 3) * 0.25;
721
+ return base + freqBonus;
722
+ }
688
723
  function scoreKeywords(resume, job, config) {
689
724
  const jobKeywordSet = new Set(
690
725
  job.keywords.map((k) => normalizeSkill(k, config.skillAliases))
691
726
  );
692
727
  if (jobKeywordSet.size === 0) {
693
- return { score: 100, matchedKeywords: [], missingKeywords: [], overusedKeywords: [] };
728
+ return {
729
+ score: 100,
730
+ matchedKeywords: [],
731
+ missingKeywords: [],
732
+ overusedKeywords: [],
733
+ keywordsByCategory: emptyCategoryBuckets(),
734
+ keywordWeights: []
735
+ };
694
736
  }
695
737
  const resumeTokens = tokenize(resume.normalizedText).map(
696
738
  (t) => normalizeSkill(t, config.skillAliases)
697
739
  );
698
740
  const resumeTokenSet = new Set(resumeTokens);
741
+ const resumeFrequencies = countFrequencies(resumeTokens);
742
+ const requiredSet = new Set(job.requiredSkills);
743
+ const preferredSet = new Set(job.preferredSkills);
744
+ const jdFrequencies = countFrequencies(
745
+ tokenize(job.normalizedText).map((t) => normalizeSkill(t, config.skillAliases))
746
+ );
747
+ const weightOf = (keyword) => keywordWeightOf(keyword, requiredSet, preferredSet, jdFrequencies);
699
748
  const matchedKeywords = [...jobKeywordSet].filter((keyword) => resumeTokenSet.has(keyword));
700
749
  const missingKeywords = [...jobKeywordSet].filter((keyword) => !resumeTokenSet.has(keyword));
701
- const coverage = matchedKeywords.length / jobKeywordSet.size;
702
- const score = clamp(coverage * 100, 0, 100);
703
- const frequencies = countFrequencies(resumeTokens);
750
+ const totalWeight = [...jobKeywordSet].reduce((sum, keyword) => sum + weightOf(keyword), 0);
751
+ const matchedWeight = matchedKeywords.reduce((sum, keyword) => sum + weightOf(keyword), 0);
752
+ const score = clamp(matchedWeight / totalWeight * 100, 0, 100);
704
753
  const totalTokens = resumeTokens.length || 1;
705
754
  const overusedKeywords = matchedKeywords.filter((keyword) => {
706
- const density = (frequencies[keyword] ?? 0) / totalTokens;
755
+ const density = (resumeFrequencies[keyword] ?? 0) / totalTokens;
707
756
  return density > config.keywordDensity.max;
708
757
  });
758
+ const keywordsByCategory = emptyCategoryBuckets();
759
+ for (const keyword of matchedKeywords) {
760
+ keywordsByCategory[config.categoryIndex.get(keyword) ?? "technical"].matched.push(keyword);
761
+ }
762
+ for (const keyword of missingKeywords) {
763
+ keywordsByCategory[config.categoryIndex.get(keyword) ?? "technical"].missing.push(keyword);
764
+ }
765
+ for (const bucket of Object.values(keywordsByCategory)) {
766
+ bucket.matched.sort();
767
+ bucket.missing.sort();
768
+ }
769
+ const keywordWeights = [...jobKeywordSet].map((term) => {
770
+ const weight = Number(weightOf(term).toFixed(2));
771
+ return {
772
+ term,
773
+ category: config.categoryIndex.get(term) ?? "technical",
774
+ jdWeight: weight,
775
+ resumeWeight: resumeFrequencies[term] ?? 0,
776
+ importance: weight
777
+ };
778
+ }).sort((a, b) => a.term.localeCompare(b.term));
709
779
  return {
710
780
  score,
711
781
  matchedKeywords: unique(matchedKeywords).sort(),
712
782
  missingKeywords: unique(missingKeywords).sort(),
713
- overusedKeywords: unique(overusedKeywords).sort()
783
+ overusedKeywords: unique(overusedKeywords).sort(),
784
+ keywordsByCategory,
785
+ keywordWeights
714
786
  };
715
787
  }
716
788
  function scoreEducation(resume, job) {
717
789
  if (job.educationRequirements.length === 0) {
718
790
  return 100;
719
791
  }
720
- const resumeEducationText = resume.educationEntries.join(" ");
721
- const normalizedEducation = resumeEducationText.toLowerCase();
792
+ const resumeDegreeLevels = extractDegreeLevels(resume.educationEntries.join(" "));
722
793
  const matched = job.educationRequirements.filter(
723
- (requirement) => normalizedEducation.includes(requirement.toLowerCase())
794
+ (requirement) => resumeDegreeLevels.includes(requirement)
724
795
  );
725
796
  if (matched.length === 0) {
726
797
  return 0;
@@ -739,6 +810,14 @@ function calculateScore(resume, job, config) {
739
810
  education: educationScore
740
811
  };
741
812
  const weightedScore = breakdown.skills * config.weights.skills + breakdown.experience * config.weights.experience + breakdown.keywords * config.weights.keywords + breakdown.education * config.weights.education;
813
+ const achievementStrength = {
814
+ strong: resume.achievements.filter((a) => a.strength === "strong").length,
815
+ weak: resume.achievements.filter((a) => a.strength === "weak").length
816
+ };
817
+ const { matched: matchedLanguages, missing: missingLanguages } = diffLanguages(
818
+ resume.languages,
819
+ job.requiredLanguages
820
+ );
742
821
  return {
743
822
  score: clamp(Number(weightedScore.toFixed(2)), 0, 100),
744
823
  breakdown,
@@ -747,6 +826,11 @@ function calculateScore(resume, job, config) {
747
826
  matchedKeywords: keywordResult.matchedKeywords,
748
827
  missingKeywords: keywordResult.missingKeywords,
749
828
  overusedKeywords: keywordResult.overusedKeywords,
829
+ keywordsByCategory: keywordResult.keywordsByCategory,
830
+ keywordWeights: keywordResult.keywordWeights,
831
+ achievementStrength,
832
+ matchedLanguages,
833
+ missingLanguages,
750
834
  suggestions: [],
751
835
  warnings: [],
752
836
  // detectedSections / parsedExperienceYears / experienceGap / experienceEntries: filled by index.ts
@@ -759,74 +843,6 @@ function calculateScore(resume, job, config) {
759
843
  };
760
844
  }
761
845
 
762
- // src/profiles/index.ts
763
- var defaultSkillAliases = {
764
- // ponytail: "node" split from javascript — Node.js runtime !== JS language
765
- javascript: ["js"],
766
- node: ["node.js", "nodejs"],
767
- typescript: ["ts"],
768
- react: ["reactjs", "react.js"],
769
- "c++": ["cpp"],
770
- "c#": ["csharp"],
771
- python: ["py"],
772
- sql: ["postgres", "mysql", "sqlite"],
773
- graphql: ["gql"],
774
- aws: ["amazon web services"],
775
- azure: ["microsoft azure"],
776
- gcp: ["google cloud", "google cloud platform"],
777
- docker: ["containers"],
778
- kubernetes: ["k8s"],
779
- html: ["html5"],
780
- css: ["css3"],
781
- // ML / data science
782
- pytorch: ["torch"],
783
- tensorflow: ["tf"],
784
- "scikit-learn": ["sklearn"],
785
- pandas: [],
786
- numpy: [],
787
- fastapi: [],
788
- flask: [],
789
- django: [],
790
- // data / infra
791
- kafka: [],
792
- redis: [],
793
- elasticsearch: ["elastic"],
794
- spark: ["apache spark"],
795
- // common pure-letter tech skills (no symbol chars)
796
- accessibility: ["a11y"],
797
- frontend: ["front-end"],
798
- backend: ["back-end"],
799
- security: ["cybersecurity"],
800
- testing: ["unittest", "pytest"],
801
- microservices: [],
802
- agile: ["scrum"],
803
- blockchain: [],
804
- devops: []
805
- };
806
- var softwareEngineerProfile = {
807
- name: "software-engineer",
808
- mandatorySkills: ["javascript", "typescript", "react", "node"],
809
- optionalSkills: ["graphql", "sql", "docker"],
810
- minExperience: 3
811
- };
812
- var dataScientistProfile = {
813
- name: "data-scientist",
814
- mandatorySkills: ["python", "sql", "statistics"],
815
- optionalSkills: ["pandas", "numpy", "pytorch", "tensorflow"],
816
- minExperience: 2
817
- };
818
- var productManagerProfile = {
819
- name: "product-manager",
820
- mandatorySkills: ["roadmap", "stakeholder management", "prioritization"],
821
- optionalSkills: ["a/b testing", "analytics", "sql"],
822
- minExperience: 3
823
- };
824
- var defaultProfiles = [
825
- softwareEngineerProfile,
826
- dataScientistProfile,
827
- productManagerProfile
828
- ];
829
-
830
846
  // src/core/scoring/weights.ts
831
847
  var DEFAULT_WEIGHTS = {
832
848
  skills: 0.3,
@@ -872,9 +888,12 @@ function resolveConfig(config = {}) {
872
888
  keywords: config.weights?.keywords ?? DEFAULT_WEIGHTS.keywords,
873
889
  education: config.weights?.education ?? DEFAULT_WEIGHTS.education
874
890
  };
891
+ const keywordRegistry = mergeKeywordRegistries(defaultKeywordRegistry, config.keywordRegistry ?? []);
875
892
  const resolved = {
876
893
  weights: normalizeWeights(weights),
877
- skillAliases: { ...defaultSkillAliases, ...config.skillAliases ?? {} },
894
+ skillAliases: { ...deriveSkillAliases(keywordRegistry), ...config.skillAliases ?? {} },
895
+ keywordRegistry,
896
+ categoryIndex: buildCategoryIndex(keywordRegistry),
878
897
  profile: config.profile ?? softwareEngineerProfile,
879
898
  rules: config.rules ?? [],
880
899
  keywordDensity: config.keywordDensity ?? DEFAULT_KEYWORD_DENSITY,
@@ -894,6 +913,18 @@ function formatList(values, max = 6) {
894
913
  const trimmed = uniqueValues.slice(0, max);
895
914
  return trimmed.join(", ") + (uniqueValues.length > max ? "..." : "");
896
915
  }
916
+ function buildAliasReplacementSuggestions(resume, job, config) {
917
+ const jobKeywordSet = new Set(job.keywords.map((k) => normalizeSkill(k, config.skillAliases)));
918
+ const replacements = [];
919
+ for (const token of unique(tokenize(resume.normalizedText))) {
920
+ const canonical = normalizeSkill(token, config.skillAliases);
921
+ const jdSurface = job.keywordSurfaceForms[canonical];
922
+ if (jdSurface && jobKeywordSet.has(canonical) && jdSurface.toLowerCase() !== token.toLowerCase()) {
923
+ replacements.push(`Replace "${token}" with "${jdSurface}" to match the job description's wording.`);
924
+ }
925
+ }
926
+ return unique(replacements).slice(0, 5);
927
+ }
897
928
  var SuggestionEngine = class {
898
929
  generate(input) {
899
930
  const suggestions = [];
@@ -908,6 +939,7 @@ var SuggestionEngine = class {
908
939
  `Incorporate job-specific keywords: ${formatList(input.score.missingKeywords)}`
909
940
  );
910
941
  }
942
+ suggestions.push(...buildAliasReplacementSuggestions(input.resume, input.job, input.config));
911
943
  if (input.score.overusedKeywords.length > 0) {
912
944
  suggestions.push(
913
945
  `Avoid keyword stuffing for: ${formatList(input.score.overusedKeywords)}`
@@ -928,6 +960,21 @@ var SuggestionEngine = class {
928
960
  "Strengthen bullet points with impact verbs (led, built, improved, delivered)."
929
961
  );
930
962
  }
963
+ if (input.resume.weakVerbs.length > 0) {
964
+ suggestions.push(
965
+ `Replace weak verbs (${formatList(input.resume.weakVerbs)}) with stronger ones (e.g. led, built, optimized).`
966
+ );
967
+ }
968
+ if (input.score.missingLanguages.length > 0) {
969
+ const formatted = input.score.missingLanguages.map((l) => l.level ? `${l.name} (${l.level})` : l.name).join(", ");
970
+ suggestions.push(`Mention your proficiency in: ${formatted}`);
971
+ }
972
+ const weakAchievement = input.resume.achievements.find((a) => a.strength === "weak");
973
+ if (weakAchievement) {
974
+ suggestions.push(
975
+ `Strengthen "${weakAchievement.text}" \u2014 add scope/metrics, e.g. "Built and maintained scalable services handling 500k+ requests/day."`
976
+ );
977
+ }
931
978
  if (input.resume.detectedSections.length < 2 && input.resume.raw.trim().length > 300) {
932
979
  suggestions.push(
933
980
  "Your resume may use a multi-column layout. Export as a single-column PDF or paste plain text \u2014 most ATS systems and this parser work best with a linear layout."
@@ -1570,7 +1617,8 @@ function analyzeResume(input) {
1570
1617
  resume: parsedResume,
1571
1618
  job: parsedJob,
1572
1619
  score: scoring,
1573
- ruleWarnings: ruleResult.warnings
1620
+ ruleWarnings: ruleResult.warnings,
1621
+ config: resolvedConfig
1574
1622
  });
1575
1623
  let suggestions = suggestionResult.suggestions;
1576
1624
  const llmWarnings = [];
@@ -1590,6 +1638,11 @@ function analyzeResume(input) {
1590
1638
  matchedKeywords: scoring.matchedKeywords,
1591
1639
  missingKeywords: scoring.missingKeywords,
1592
1640
  overusedKeywords: scoring.overusedKeywords,
1641
+ keywordsByCategory: scoring.keywordsByCategory,
1642
+ keywordWeights: scoring.keywordWeights,
1643
+ achievementStrength: scoring.achievementStrength,
1644
+ matchedLanguages: scoring.matchedLanguages,
1645
+ missingLanguages: scoring.missingLanguages,
1593
1646
  experienceGap: scoring.experienceGap,
1594
1647
  detectedSections: parsedResume.detectedSections,
1595
1648
  parsedExperienceYears: parsedResume.totalExperienceYears,
@@ -1632,7 +1685,8 @@ async function analyzeResumeAsync(input) {
1632
1685
  resume: parsedResume,
1633
1686
  job: parsedJob,
1634
1687
  score: scoring,
1635
- ruleWarnings: ruleResult.warnings
1688
+ ruleWarnings: ruleResult.warnings,
1689
+ config: resolvedConfig
1636
1690
  });
1637
1691
  let suggestions = suggestionResult.suggestions;
1638
1692
  const llmWarnings = [];
@@ -1655,6 +1709,11 @@ async function analyzeResumeAsync(input) {
1655
1709
  matchedKeywords: scoring.matchedKeywords,
1656
1710
  missingKeywords: scoring.missingKeywords,
1657
1711
  overusedKeywords: scoring.overusedKeywords,
1712
+ keywordsByCategory: scoring.keywordsByCategory,
1713
+ keywordWeights: scoring.keywordWeights,
1714
+ achievementStrength: scoring.achievementStrength,
1715
+ matchedLanguages: scoring.matchedLanguages,
1716
+ missingLanguages: scoring.missingLanguages,
1658
1717
  experienceGap: scoring.experienceGap,
1659
1718
  detectedSections: parsedResume.detectedSections,
1660
1719
  parsedExperienceYears: parsedResume.totalExperienceYears,
@@ -1699,6 +1758,6 @@ async function enhanceSuggestionsWithLLMAsync(config, suggestions) {
1699
1758
  }
1700
1759
  }
1701
1760
 
1702
- export { LLMBudgetManager, LLMManager, LLMPrompts, LLMSchemas, adaptJdClarificationResponse, adaptSectionClassificationResponse, adaptSkillNormalizationResponse, adaptSuggestionEnhancementResponse, analyzeResume, analyzeResumeAsync, createPrompt, defaultProfiles, defaultSkillAliases, safeExtractArray, safeExtractNumber, safeExtractString };
1761
+ export { LLMBudgetManager, LLMManager, LLMPrompts, LLMSchemas, adaptJdClarificationResponse, adaptSectionClassificationResponse, adaptSkillNormalizationResponse, adaptSuggestionEnhancementResponse, analyzeResume, analyzeResumeAsync, createPrompt, safeExtractArray, safeExtractNumber, safeExtractString };
1703
1762
  //# sourceMappingURL=index.mjs.map
1704
1763
  //# sourceMappingURL=index.mjs.map