@pranavraut033/ats-checker 1.3.3 → 2.0.0

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.cjs CHANGED
@@ -1,5 +1,228 @@
1
1
  'use strict';
2
2
 
3
+ // src/utils/languages.ts
4
+ var KNOWN_LANGUAGES = [
5
+ "english",
6
+ "spanish",
7
+ "french",
8
+ "german",
9
+ "italian",
10
+ "portuguese",
11
+ "dutch",
12
+ "russian",
13
+ "mandarin",
14
+ "chinese",
15
+ "cantonese",
16
+ "japanese",
17
+ "korean",
18
+ "arabic",
19
+ "hindi",
20
+ "polish",
21
+ "turkish",
22
+ "vietnamese",
23
+ "swedish",
24
+ "norwegian",
25
+ "danish",
26
+ "finnish",
27
+ "greek",
28
+ "hebrew",
29
+ "thai",
30
+ "indonesian",
31
+ "ukrainian",
32
+ "czech"
33
+ ];
34
+ var LANGUAGE_ALIASES = {
35
+ mandarin: "chinese",
36
+ cantonese: "chinese"
37
+ };
38
+ var LEVEL_RANK = {
39
+ a1: 1,
40
+ a2: 2,
41
+ b1: 3,
42
+ b2: 4,
43
+ c1: 5,
44
+ c2: 6,
45
+ basic: 1,
46
+ elementary: 1,
47
+ limited: 2,
48
+ conversational: 3,
49
+ intermediate: 3,
50
+ professional: 4,
51
+ "upper intermediate": 4,
52
+ advanced: 4,
53
+ fluent: 5,
54
+ native: 6,
55
+ "native speaker": 6,
56
+ bilingual: 6,
57
+ // German
58
+ grundkenntnisse: 1,
59
+ gering: 2,
60
+ gut: 3,
61
+ fortgeschritten: 4,
62
+ flie\u00DFend: 5,
63
+ muttersprache: 6,
64
+ muttersprachler: 6,
65
+ // French
66
+ d\u00E9butant: 1,
67
+ \u00E9l\u00E9mentaire: 1,
68
+ limit\u00E9: 2,
69
+ interm\u00E9diaire: 3,
70
+ avanc\u00E9: 4,
71
+ courant: 5,
72
+ natif: 6,
73
+ "langue maternelle": 6,
74
+ bilingue: 6
75
+ };
76
+ var LANGUAGE_GROUP = KNOWN_LANGUAGES.join("|");
77
+ var LEVEL_GROUP = Object.keys(LEVEL_RANK).sort((a, b) => b.length - a.length).map((l) => l.replace(/\s+/g, "\\s+")).join("|");
78
+ var BOUNDARY_START = "(?:^|(?<=[^a-z\xE0-\xFF]))";
79
+ var BOUNDARY_END = "(?:$|(?=[^a-z\xE0-\xFF]))";
80
+ var LANGUAGE_LEVEL_RE = new RegExp(
81
+ `\\b(${LANGUAGE_GROUP})\\b(?:\\s*[\\(:\\-]?\\s*(${BOUNDARY_START}(?:${LEVEL_GROUP})${BOUNDARY_END}|[abc][12]))?`,
82
+ "gi"
83
+ );
84
+ var LEVEL_BEFORE_LANGUAGE_RE = new RegExp(
85
+ `${BOUNDARY_START}(${LEVEL_GROUP})${BOUNDARY_END}\\s+(?:in\\s+)?(${LANGUAGE_GROUP})\\b`,
86
+ "gi"
87
+ );
88
+ function canonicalLanguage(name) {
89
+ const lower = name.toLowerCase();
90
+ return LANGUAGE_ALIASES[lower] ?? lower;
91
+ }
92
+ function toParsedLanguage(name, level) {
93
+ const normalizedLevel = level?.toLowerCase().replace(/\s+/g, " ");
94
+ return {
95
+ name: canonicalLanguage(name),
96
+ level: normalizedLevel,
97
+ levelRank: normalizedLevel ? LEVEL_RANK[normalizedLevel] : void 0
98
+ };
99
+ }
100
+ function parseLanguageMentions(text) {
101
+ const found = /* @__PURE__ */ new Map();
102
+ for (const match of text.matchAll(LANGUAGE_LEVEL_RE)) {
103
+ const parsed = toParsedLanguage(match[1], match[2]);
104
+ const existing = found.get(parsed.name);
105
+ if (!existing || (parsed.levelRank ?? 0) > (existing.levelRank ?? 0)) {
106
+ found.set(parsed.name, parsed);
107
+ }
108
+ }
109
+ for (const match of text.matchAll(LEVEL_BEFORE_LANGUAGE_RE)) {
110
+ const parsed = toParsedLanguage(match[2], match[1]);
111
+ const existing = found.get(parsed.name);
112
+ if (!existing || (parsed.levelRank ?? 0) > (existing.levelRank ?? 0)) {
113
+ found.set(parsed.name, parsed);
114
+ }
115
+ }
116
+ return [...found.values()].sort((a, b) => a.name.localeCompare(b.name));
117
+ }
118
+ function diffLanguages(resumeLanguages, requiredLanguages) {
119
+ const byName = new Map(resumeLanguages.map((l) => [l.name, l]));
120
+ const matched = [];
121
+ const missing = [];
122
+ for (const required of requiredLanguages) {
123
+ const have = byName.get(required.name);
124
+ const requiredRank = required.levelRank ?? 0;
125
+ const haveRank = have?.levelRank ?? 0;
126
+ if (have && haveRank >= requiredRank) {
127
+ matched.push(required);
128
+ } else {
129
+ missing.push(required);
130
+ }
131
+ }
132
+ return { matched, missing };
133
+ }
134
+
135
+ // src/utils/match.ts
136
+ var SUFFIXES = [
137
+ { suffix: "ational", replacement: "ate", minStemLength: 3 },
138
+ { suffix: "ization", replacement: "ize", minStemLength: 3 },
139
+ { suffix: "ations", replacement: "ate", minStemLength: 3 },
140
+ { suffix: "ation", replacement: "ate", minStemLength: 3 },
141
+ { suffix: "ements", replacement: "e", minStemLength: 3 },
142
+ { suffix: "ement", replacement: "e", minStemLength: 3 },
143
+ { suffix: "ments", replacement: "ment", minStemLength: 3 },
144
+ { suffix: "ment", replacement: "", minStemLength: 3 },
145
+ { suffix: "ies", replacement: "y", minStemLength: 2 },
146
+ { suffix: "ied", replacement: "y", minStemLength: 2 },
147
+ { suffix: "ying", replacement: "y", minStemLength: 2 },
148
+ { suffix: "tion", replacement: "t", minStemLength: 3 },
149
+ { suffix: "sion", replacement: "s", minStemLength: 3 },
150
+ { suffix: "ness", replacement: "", minStemLength: 3 },
151
+ { suffix: "fully", replacement: "ful", minStemLength: 3 },
152
+ { suffix: "ally", replacement: "al", minStemLength: 3 },
153
+ { suffix: "ing", replacement: "", minStemLength: 3 },
154
+ { suffix: "edly", replacement: "", minStemLength: 3 },
155
+ { suffix: "ed", replacement: "", minStemLength: 3 },
156
+ { suffix: "ly", replacement: "", minStemLength: 3 },
157
+ { suffix: "er", replacement: "", minStemLength: 3 },
158
+ { suffix: "es", replacement: "", minStemLength: 3 },
159
+ { suffix: "s", replacement: "", minStemLength: 3 }
160
+ ];
161
+ function stripOneSuffix(lower) {
162
+ for (const { suffix, replacement, minStemLength } of SUFFIXES) {
163
+ if (lower.endsWith(suffix) && lower.length - suffix.length >= minStemLength) {
164
+ let stemmed = lower.slice(0, lower.length - suffix.length) + replacement;
165
+ if (replacement === "" && stemmed.length >= 3 && stemmed[stemmed.length - 1] === stemmed[stemmed.length - 2] && !/[aeiou]/.test(stemmed[stemmed.length - 1])) {
166
+ stemmed = stemmed.slice(0, -1);
167
+ }
168
+ return stemmed;
169
+ }
170
+ }
171
+ return lower;
172
+ }
173
+ function stem(token) {
174
+ let word = token.trim().toLowerCase();
175
+ for (let pass = 0; pass < 2 && word.length > 3; pass++) {
176
+ const next = stripOneSuffix(word);
177
+ if (next === word) {
178
+ break;
179
+ }
180
+ word = next;
181
+ }
182
+ return word;
183
+ }
184
+ function levenshteinDistance(a, b, maxDistance) {
185
+ const s = a.toLowerCase();
186
+ const t = b.toLowerCase();
187
+ if (s === t) return 0;
188
+ const lenDiff = Math.abs(s.length - t.length);
189
+ if (lenDiff > maxDistance) return maxDistance + 1;
190
+ let prevRow = new Array(t.length + 1);
191
+ for (let j = 0; j <= t.length; j++) prevRow[j] = j;
192
+ for (let i = 1; i <= s.length; i++) {
193
+ const currRow = new Array(t.length + 1);
194
+ currRow[0] = i;
195
+ let rowMin = currRow[0];
196
+ for (let j = 1; j <= t.length; j++) {
197
+ const cost = s[i - 1] === t[j - 1] ? 0 : 1;
198
+ currRow[j] = Math.min(
199
+ prevRow[j] + 1,
200
+ // deletion
201
+ currRow[j - 1] + 1,
202
+ // insertion
203
+ prevRow[j - 1] + cost
204
+ // substitution
205
+ );
206
+ if (currRow[j] < rowMin) rowMin = currRow[j];
207
+ }
208
+ if (rowMin > maxDistance) {
209
+ return maxDistance + 1;
210
+ }
211
+ prevRow = currRow;
212
+ }
213
+ return prevRow[t.length];
214
+ }
215
+ function fuzzyEqual(a, b, opts) {
216
+ const x = a.trim().toLowerCase();
217
+ const y = b.trim().toLowerCase();
218
+ if (x === y) return true;
219
+ if (!x || !y) return false;
220
+ const longer = Math.max(x.length, y.length);
221
+ const defaultMax = longer <= 6 ? 1 : 2;
222
+ const maxDistance = opts?.maxDistance ?? defaultMax;
223
+ return levenshteinDistance(x, y, maxDistance) <= maxDistance;
224
+ }
225
+
3
226
  // src/utils/text.ts
4
227
  var STOP_WORDS = /* @__PURE__ */ new Set([
5
228
  // articles / prepositions / conjunctions
@@ -129,6 +352,39 @@ var STOP_WORDS = /* @__PURE__ */ new Set([
129
352
  "open",
130
353
  "dynamic"
131
354
  ]);
355
+ var ROLE_NOUNS = [
356
+ "engineer",
357
+ "developer",
358
+ "manager",
359
+ "scientist",
360
+ "analyst",
361
+ "designer",
362
+ "architect",
363
+ "director",
364
+ "consultant",
365
+ "lead",
366
+ "vp",
367
+ "specialist",
368
+ "coordinator",
369
+ "administrator",
370
+ "accountant",
371
+ "recruiter",
372
+ "nurse",
373
+ "teacher",
374
+ "associate",
375
+ "representative",
376
+ "executive",
377
+ "officer",
378
+ "technician",
379
+ "strategist",
380
+ "marketer",
381
+ "writer",
382
+ "editor",
383
+ "product",
384
+ "principal",
385
+ "staff",
386
+ "head"
387
+ ];
132
388
  function normalizeWhitespace(text) {
133
389
  return text.replace(/\r\n?/g, "\n").replace(/\s+/g, " ").trim();
134
390
  }
@@ -139,11 +395,14 @@ function splitLines(text) {
139
395
  return text.replace(/\r\n?/g, "\n").split("\n").map((line) => line.trim()).filter(Boolean);
140
396
  }
141
397
  var TECH_TOKEN_RE = /[a-z0-9][a-z0-9.#+\-/]*[a-z0-9#+]/g;
142
- function tokenize(text) {
398
+ function tokenize(text, options) {
143
399
  const normalized = normalizeForComparison(text);
144
- return (normalized.match(TECH_TOKEN_RE) ?? []).filter(
400
+ const tokens = (normalized.match(TECH_TOKEN_RE) ?? []).filter(
145
401
  (t) => /[a-z]/.test(t) && !STOP_WORDS.has(t)
146
402
  );
403
+ {
404
+ return tokens;
405
+ }
147
406
  }
148
407
  function escapeRegExp(input) {
149
408
  return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -183,9 +442,48 @@ function containsTableLikeStructure(text) {
183
442
  }
184
443
  return tableLines >= 2;
185
444
  }
445
+ var STANDARD_BULLET_RE = /^\s*(?:[-*•o‣▪]|\(?[a-z0-9]+[.)])\s+/i;
446
+ var NON_STANDARD_BULLET_RE = /^\s*[➤➢✓✔✗➔❖◆♦❯›»★☆♥❤]/;
447
+ var SPECIAL_CHAR_RE = /[-�]|[\x00-\x08\x0B\x0C\x0E-\x1F]/;
448
+ function detectFormatting(raw) {
449
+ const lines = splitLines(raw);
450
+ const hasTables = containsTableLikeStructure(raw);
451
+ let wideGapLines = 0;
452
+ for (const line of lines) {
453
+ if (/\S( {6,})\S/.test(line)) {
454
+ wideGapLines += 1;
455
+ }
456
+ }
457
+ const hasMultiColumn = wideGapLines >= 2;
458
+ const hasSpecialChars = SPECIAL_CHAR_RE.test(raw);
459
+ let bulletLines = 0;
460
+ let nonStandardBulletLines = 0;
461
+ for (const line of lines) {
462
+ if (NON_STANDARD_BULLET_RE.test(line)) {
463
+ bulletLines += 1;
464
+ nonStandardBulletLines += 1;
465
+ } else if (STANDARD_BULLET_RE.test(line)) {
466
+ bulletLines += 1;
467
+ }
468
+ }
469
+ const nonStandardBullets = nonStandardBulletLines > 0 && nonStandardBulletLines >= bulletLines / 2;
470
+ const trimmed = raw.trim();
471
+ const letterCount = (trimmed.match(/[a-zA-Z]/g) ?? []).length;
472
+ const likelyScanned = trimmed.length > 0 && (letterCount < 20 || letterCount / trimmed.length < 0.2);
473
+ const contactParseable = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/.test(raw);
474
+ return {
475
+ hasTables,
476
+ hasMultiColumn,
477
+ hasSpecialChars,
478
+ nonStandardBullets,
479
+ likelyScanned,
480
+ contactParseable
481
+ };
482
+ }
186
483
 
187
484
  // src/utils/skills.ts
188
485
  var aliasIndexCache = /* @__PURE__ */ new WeakMap();
486
+ var stemIndexCache = /* @__PURE__ */ new WeakMap();
189
487
  function getAliasIndex(aliases) {
190
488
  let index = aliasIndexCache.get(aliases);
191
489
  if (!index) {
@@ -201,12 +499,44 @@ function getAliasIndex(aliases) {
201
499
  }
202
500
  return index;
203
501
  }
204
- function normalizeSkill(skill, aliases) {
502
+ function getStemIndex(aliases) {
503
+ let index = stemIndexCache.get(aliases);
504
+ if (!index) {
505
+ index = /* @__PURE__ */ new Map();
506
+ for (const [key, canonical] of getAliasIndex(aliases)) {
507
+ const stemmed = stem(key);
508
+ if (!index.has(stemmed)) {
509
+ index.set(stemmed, canonical);
510
+ }
511
+ }
512
+ stemIndexCache.set(aliases, index);
513
+ }
514
+ return index;
515
+ }
516
+ function normalizeSkill(skill, aliases, options) {
205
517
  const normalized = skill.trim().toLowerCase();
206
- return getAliasIndex(aliases).get(normalized) ?? normalized;
518
+ const exact = getAliasIndex(aliases).get(normalized);
519
+ if (exact) {
520
+ return exact;
521
+ }
522
+ if (!options?.fuzzy) {
523
+ return normalized;
524
+ }
525
+ const stemmed = stem(normalized);
526
+ const stemIndex = getStemIndex(aliases);
527
+ const stemHit = stemIndex.get(stemmed);
528
+ if (stemHit) {
529
+ return stemHit;
530
+ }
531
+ for (const [key, canonical] of stemIndex) {
532
+ if (fuzzyEqual(stemmed, key, { maxDistance: options.maxDistance })) {
533
+ return canonical;
534
+ }
535
+ }
536
+ return normalized;
207
537
  }
208
- function normalizeSkills(skills, aliases) {
209
- return unique(skills.map((skill) => normalizeSkill(skill, aliases)));
538
+ function normalizeSkills(skills, aliases, options) {
539
+ return unique(skills.map((skill) => normalizeSkill(skill, aliases, options)));
210
540
  }
211
541
  function deriveSkillAliases(registry) {
212
542
  const aliases = {};
@@ -224,141 +554,72 @@ function buildCategoryIndex(registry) {
224
554
  }
225
555
  function mergeKeywordRegistries(base, overrides) {
226
556
  const byCanonical = /* @__PURE__ */ new Map();
227
- for (const entry of base) byCanonical.set(entry.canonical.toLowerCase(), entry);
228
- for (const entry of overrides) byCanonical.set(entry.canonical.toLowerCase(), entry);
557
+ for (const entry of base)
558
+ byCanonical.set(entry.canonical.toLowerCase(), entry);
559
+ for (const entry of overrides)
560
+ byCanonical.set(entry.canonical.toLowerCase(), entry);
229
561
  return [...byCanonical.values()];
230
562
  }
231
563
 
232
- // src/utils/languages.ts
233
- var KNOWN_LANGUAGES = [
234
- "english",
235
- "spanish",
236
- "french",
237
- "german",
238
- "italian",
239
- "portuguese",
240
- "dutch",
241
- "russian",
242
- "mandarin",
243
- "chinese",
244
- "cantonese",
245
- "japanese",
246
- "korean",
247
- "arabic",
248
- "hindi",
249
- "polish",
250
- "turkish",
251
- "vietnamese",
252
- "swedish",
253
- "norwegian",
254
- "danish",
255
- "finnish",
256
- "greek",
257
- "hebrew",
258
- "thai",
259
- "indonesian",
260
- "ukrainian",
261
- "czech"
564
+ // src/utils/titles.ts
565
+ var SENIORITY_SIGNALS = [
566
+ { seniority: "principal", pattern: /\bprincipal\b/i },
567
+ { seniority: "lead", pattern: /\b(lead|staff|head of|director)\b/i },
568
+ { seniority: "senior", pattern: /\b(senior|sr\.?)\b/i },
569
+ {
570
+ seniority: "junior",
571
+ pattern: /\b(junior|jr\.?|entry[\s-]?level|intern(?:ship)?|associate)\b/i
572
+ }
262
573
  ];
263
- var LANGUAGE_ALIASES = {
264
- mandarin: "chinese",
265
- cantonese: "chinese"
574
+ var SENIORITY_RANK = {
575
+ junior: 0,
576
+ mid: 1,
577
+ senior: 2,
578
+ lead: 3,
579
+ principal: 4
266
580
  };
267
- var LEVEL_RANK = {
268
- a1: 1,
269
- a2: 2,
270
- b1: 3,
271
- b2: 4,
272
- c1: 5,
273
- c2: 6,
274
- basic: 1,
275
- elementary: 1,
276
- limited: 2,
277
- conversational: 3,
278
- intermediate: 3,
279
- professional: 4,
280
- "upper intermediate": 4,
281
- advanced: 4,
282
- fluent: 5,
283
- native: 6,
284
- "native speaker": 6,
285
- bilingual: 6,
286
- // German
287
- grundkenntnisse: 1,
288
- gering: 2,
289
- gut: 3,
290
- fortgeschritten: 4,
291
- flie\u00DFend: 5,
292
- muttersprache: 6,
293
- muttersprachler: 6,
294
- // French
295
- "d\xE9butant": 1,
296
- "\xE9l\xE9mentaire": 1,
297
- "limit\xE9": 2,
298
- "interm\xE9diaire": 3,
299
- "avanc\xE9": 4,
300
- courant: 5,
301
- natif: 6,
302
- "langue maternelle": 6,
303
- bilingue: 6
304
- };
305
- var LANGUAGE_GROUP = KNOWN_LANGUAGES.join("|");
306
- var LEVEL_GROUP = Object.keys(LEVEL_RANK).sort((a, b) => b.length - a.length).map((l) => l.replace(/\s+/g, "\\s+")).join("|");
307
- var BOUNDARY_START = "(?:^|(?<=[^a-z\xE0-\xFF]))";
308
- var BOUNDARY_END = "(?:$|(?=[^a-z\xE0-\xFF]))";
309
- var LANGUAGE_LEVEL_RE = new RegExp(
310
- `\\b(${LANGUAGE_GROUP})\\b(?:\\s*[\\(:\\-]?\\s*(${BOUNDARY_START}(?:${LEVEL_GROUP})${BOUNDARY_END}|[abc][12]))?`,
311
- "gi"
312
- );
313
- var LEVEL_BEFORE_LANGUAGE_RE = new RegExp(
314
- `${BOUNDARY_START}(${LEVEL_GROUP})${BOUNDARY_END}\\s+(?:in\\s+)?(${LANGUAGE_GROUP})\\b`,
315
- "gi"
316
- );
317
- function canonicalLanguage(name) {
318
- const lower = name.toLowerCase();
319
- return LANGUAGE_ALIASES[lower] ?? lower;
320
- }
321
- function toParsedLanguage(name, level) {
322
- const normalizedLevel = level?.toLowerCase().replace(/\s+/g, " ");
323
- return {
324
- name: canonicalLanguage(name),
325
- level: normalizedLevel,
326
- levelRank: normalizedLevel ? LEVEL_RANK[normalizedLevel] : void 0
327
- };
328
- }
329
- function parseLanguageMentions(text) {
330
- const found = /* @__PURE__ */ new Map();
331
- for (const match of text.matchAll(LANGUAGE_LEVEL_RE)) {
332
- const parsed = toParsedLanguage(match[1], match[2]);
333
- const existing = found.get(parsed.name);
334
- if (!existing || (parsed.levelRank ?? 0) > (existing.levelRank ?? 0)) {
335
- found.set(parsed.name, parsed);
336
- }
337
- }
338
- for (const match of text.matchAll(LEVEL_BEFORE_LANGUAGE_RE)) {
339
- const parsed = toParsedLanguage(match[2], match[1]);
340
- const existing = found.get(parsed.name);
341
- if (!existing || (parsed.levelRank ?? 0) > (existing.levelRank ?? 0)) {
342
- found.set(parsed.name, parsed);
581
+ function inferSeniority(titles) {
582
+ let best;
583
+ for (const title of titles) {
584
+ for (const { seniority, pattern } of SENIORITY_SIGNALS) {
585
+ if (pattern.test(title)) {
586
+ if (!best || SENIORITY_RANK[seniority] > SENIORITY_RANK[best]) {
587
+ best = seniority;
588
+ }
589
+ break;
590
+ }
343
591
  }
344
592
  }
345
- return [...found.values()].sort((a, b) => a.name.localeCompare(b.name));
593
+ return best;
346
594
  }
347
- function diffLanguages(resumeLanguages, requiredLanguages) {
348
- const byName = new Map(resumeLanguages.map((l) => [l.name, l]));
349
- const matched = [];
350
- const missing = [];
351
- for (const required of requiredLanguages) {
352
- const have = byName.get(required.name);
353
- const requiredRank = required.levelRank ?? 0;
354
- const haveRank = have?.levelRank ?? 0;
355
- if (have && haveRank >= requiredRank) {
356
- matched.push(required);
357
- } else {
358
- missing.push(required);
595
+ function normalizeTitle(title) {
596
+ return title.toLowerCase().replace(/[.,/#!$%^&*;:{}=_`~()]/g, " ").replace(/\s+/g, " ").trim();
597
+ }
598
+ function titleTokens(title) {
599
+ const tokens = normalizeTitle(title).split(" ").filter((t) => t && !STOP_WORDS.has(t)).map((t) => stem(t));
600
+ return new Set(tokens);
601
+ }
602
+ function titleMatch(resumeTitles, jdRoleKeywords) {
603
+ const keywords = jdRoleKeywords.map((k) => stem(normalizeTitle(k))).filter(Boolean);
604
+ if (keywords.length === 0) {
605
+ return 0;
606
+ }
607
+ const resumeTokenSets = resumeTitles.map(titleTokens);
608
+ const roleNounStems = new Set(ROLE_NOUNS.map((n) => stem(n)));
609
+ let matched = 0;
610
+ for (const keyword of keywords) {
611
+ const isRoleNoun = roleNounStems.has(keyword);
612
+ const hit = resumeTokenSets.some((tokens) => {
613
+ if (tokens.has(keyword)) {
614
+ return true;
615
+ }
616
+ return isRoleNoun && [...tokens].some((t) => roleNounStems.has(t) && t === keyword);
617
+ });
618
+ if (hit) {
619
+ matched += 1;
359
620
  }
360
621
  }
361
- return { matched, missing };
622
+ return matched / keywords.length;
362
623
  }
363
624
 
364
625
  // src/core/parser/jd.parser.ts
@@ -387,13 +648,49 @@ function extractPreferredSkills(lines) {
387
648
  }
388
649
  return preferred;
389
650
  }
651
+ var REQUIRED_HEADER_RE = /^(?:requirements?|must[\s-]?haves?|required\s+skills?|required\s+qualifications?|qualifications?|minimum\s+qualifications?)\s*:?\s*$/i;
652
+ var PREFERRED_HEADER_RE = /^(?:preferred(?:\s+skills?|\s+qualifications?)?|nice[\s-]?to[\s-]?haves?|bonus(?:\s+points?)?|pluses?)\s*:?\s*$/i;
653
+ var OTHER_HEADER_RE = /^(?:responsibilities|duties|about(?:\s+the)?(?:\s+role|\s+us|\s+company|\s+team)?|what\s+you.?ll\s+do|who\s+you\s+are|benefits|perks|compensation|how\s+to\s+apply|overview|summary|about)\s*:?\s*$/i;
654
+ function extractHeaderScopedSkills(lines) {
655
+ const required = [];
656
+ const preferred = [];
657
+ let scope = null;
658
+ for (const line of lines) {
659
+ if (REQUIRED_HEADER_RE.test(line)) {
660
+ scope = "required";
661
+ continue;
662
+ }
663
+ if (PREFERRED_HEADER_RE.test(line)) {
664
+ scope = "preferred";
665
+ continue;
666
+ }
667
+ if (OTHER_HEADER_RE.test(line)) {
668
+ scope = null;
669
+ continue;
670
+ }
671
+ if (scope === "required") {
672
+ required.push(...tokenize(line));
673
+ } else if (scope === "preferred") {
674
+ preferred.push(...tokenize(line));
675
+ }
676
+ }
677
+ return { required, preferred };
678
+ }
679
+ var ROLE_NOUN_RE = new RegExp(`(${ROLE_NOUNS.join("|")})`, "gi");
390
680
  function extractRoleKeywords(text) {
391
- const roleMatches = text.match(/(engineer|developer|manager|scientist|analyst|designer|architect|director|consultant|lead|vp)/gi) ?? [];
681
+ const roleMatches = text.match(ROLE_NOUN_RE) ?? [];
392
682
  const fallback = roleMatches.length === 0 ? [text.split(/\n/)[0] ?? ""] : [];
393
683
  return unique(tokenize([...roleMatches, ...fallback].join(" ")));
394
684
  }
685
+ function extractTitleContextLines(text) {
686
+ const lines = splitLines(text);
687
+ const roleLines = lines.filter((line) => line.match(ROLE_NOUN_RE) !== null);
688
+ return roleLines.length > 0 ? roleLines : lines.slice(0, 1);
689
+ }
395
690
  function extractMinExperience(text) {
396
- const match = text.match(/(\d{1,2})\+?\s*(?:years?|yrs\.?|jahre?|ans?|années?)/i);
691
+ const match = text.match(
692
+ /(\d{1,2})\+?\s*(?:years?|yrs\.?|jahre?|ans?|années?)/i
693
+ );
397
694
  if (!match) return void 0;
398
695
  const parsed = Number.parseInt(match[1], 10);
399
696
  return parsed <= 60 ? parsed : void 0;
@@ -419,6 +716,38 @@ function isLanguageRequired(lang, jobDescription) {
419
716
  return LANG_SECTION_RE.test(line) || LANG_REQUIREMENT_HINT_RE.test(line);
420
717
  });
421
718
  }
719
+ var YEARS_BEFORE_SKILL_RE = /(\d{1,2})\+?\s*years?\s*(?:of|in|with)?\s*((?:[a-z0-9][a-z0-9.#+\-/]*\s*){1,4})/gi;
720
+ var SKILL_BEFORE_YEARS_RE = /((?:[a-z0-9][a-z0-9.#+\-/]*[\s,]*){1,4})\(?(\d{1,2})\+?\s*years?\)?/gi;
721
+ function extractSkillExperienceRequirements(lines, skillVocab, aliases) {
722
+ const requirements = /* @__PURE__ */ new Map();
723
+ const record = (rawWord, years) => {
724
+ if (!Number.isFinite(years) || years <= 0 || years > 60) return;
725
+ const canonical = normalizeSkill(rawWord, aliases);
726
+ if (!skillVocab.has(rawWord.toLowerCase()) && !skillVocab.has(canonical))
727
+ return;
728
+ const existing = requirements.get(canonical);
729
+ if (existing === void 0 || years > existing) {
730
+ requirements.set(canonical, years);
731
+ }
732
+ };
733
+ for (const line of lines) {
734
+ for (const re of [YEARS_BEFORE_SKILL_RE, SKILL_BEFORE_YEARS_RE]) {
735
+ re.lastIndex = 0;
736
+ let match;
737
+ while (match = re.exec(line)) {
738
+ const years = Number.parseInt(
739
+ re === YEARS_BEFORE_SKILL_RE ? match[1] : match[2],
740
+ 10
741
+ );
742
+ const candidateWords = tokenize(
743
+ re === YEARS_BEFORE_SKILL_RE ? match[2] : match[1]
744
+ );
745
+ for (const word of candidateWords) record(word, years);
746
+ }
747
+ }
748
+ }
749
+ return [...requirements.entries()].map(([skill, years]) => ({ skill, years })).sort((a, b) => a.skill.localeCompare(b.skill));
750
+ }
422
751
  function extractDegreeLevels(text) {
423
752
  const found = /* @__PURE__ */ new Set();
424
753
  for (const [pattern, canonical] of DEGREE_VARIANTS) {
@@ -434,21 +763,47 @@ function parseJobDescription(jobDescription, config) {
434
763
  skillVocab.add(canonical.toLowerCase());
435
764
  for (const alias of aliases) skillVocab.add(alias.toLowerCase());
436
765
  }
437
- for (const s of config.profile?.mandatorySkills ?? []) skillVocab.add(s.toLowerCase());
438
- for (const s of config.profile?.optionalSkills ?? []) skillVocab.add(s.toLowerCase());
766
+ for (const s of config.profile?.mandatorySkills ?? [])
767
+ skillVocab.add(s.toLowerCase());
768
+ for (const s of config.profile?.optionalSkills ?? [])
769
+ skillVocab.add(s.toLowerCase());
439
770
  const isSkillLike = (t) => {
440
771
  if (skillVocab.has(t)) return true;
441
772
  if (/[.#+]/.test(t) && /[a-z]/.test(t)) return true;
442
- if (t.includes("/")) return t.split("/").some((p) => p.length >= 2 && !STOP_WORDS.has(p));
773
+ if (t.includes("/"))
774
+ return t.split("/").some((p) => p.length >= 2 && !STOP_WORDS.has(p));
443
775
  return false;
444
776
  };
445
- const requiredSkillsRaw = extractRequiredSkills(lines).filter(isSkillLike);
446
- const preferredSkillsRaw = extractPreferredSkills(lines).filter(isSkillLike);
447
- const requiredSkills = normalizeSkills(requiredSkillsRaw, config.skillAliases);
448
- const preferredSkills = normalizeSkills(preferredSkillsRaw, config.skillAliases);
777
+ const headerScoped = extractHeaderScopedSkills(lines);
778
+ const requiredSkillsRaw = unique([
779
+ ...extractRequiredSkills(lines),
780
+ ...headerScoped.required
781
+ ]).filter(isSkillLike);
782
+ const preferredSkillsRaw = unique([
783
+ ...extractPreferredSkills(lines),
784
+ ...headerScoped.preferred
785
+ ]).filter(isSkillLike);
786
+ const requiredSkills = normalizeSkills(
787
+ requiredSkillsRaw,
788
+ config.skillAliases
789
+ );
790
+ const preferredSkills = normalizeSkills(
791
+ preferredSkillsRaw,
792
+ config.skillAliases
793
+ );
449
794
  const bodyTokens = tokenize(normalizedText).filter(isSkillLike);
450
795
  const roleKeywords = extractRoleKeywords(jobDescription);
451
- const keywords = unique([...requiredSkills, ...preferredSkills, ...roleKeywords, ...bodyTokens]);
796
+ const keywords = unique([
797
+ ...requiredSkills,
798
+ ...preferredSkills,
799
+ ...roleKeywords,
800
+ ...bodyTokens
801
+ ]);
802
+ const skillExperienceRequirements = extractSkillExperienceRequirements(
803
+ lines,
804
+ skillVocab,
805
+ config.skillAliases
806
+ );
452
807
  return {
453
808
  raw: jobDescription,
454
809
  normalizedText,
@@ -457,13 +812,18 @@ function parseJobDescription(jobDescription, config) {
457
812
  roleKeywords,
458
813
  keywords,
459
814
  minExperienceYears: extractMinExperience(jobDescription),
815
+ skillExperienceRequirements,
460
816
  educationRequirements: extractDegreeLevels(jobDescription),
461
- keywordSurfaceForms: collectKeywordSurfaceForms(jobDescription, config.skillAliases),
817
+ keywordSurfaceForms: collectKeywordSurfaceForms(
818
+ jobDescription,
819
+ config.skillAliases
820
+ ),
462
821
  // A language only counts as required if its mention carries a requirement/level cue
463
822
  // or sits in a "Languages:" line — plain references ("our Berlin office") don't count.
464
823
  requiredLanguages: parseLanguageMentions(jobDescription).filter(
465
824
  (lang) => isLanguageRequired(lang, jobDescription)
466
- )
825
+ ),
826
+ seniority: inferSeniority(extractTitleContextLines(jobDescription))
467
827
  };
468
828
  }
469
829
 
@@ -561,7 +921,9 @@ function parseDateRange(text, referenceDate) {
561
921
  }
562
922
  const startToken = parseDateToken(rangeMatch[1]);
563
923
  const endRaw = rangeMatch[2];
564
- const isPresent = /present|current|now|aktuell|heute|actuellement|présent|actuel/i.test(endRaw);
924
+ const isPresent = /present|current|now|aktuell|heute|actuellement|présent|actuel/i.test(
925
+ endRaw
926
+ );
565
927
  const endToken = isPresent ? void 0 : parseDateToken(endRaw);
566
928
  if (!startToken) {
567
929
  return null;
@@ -609,27 +971,92 @@ function sumExperienceYears(ranges) {
609
971
  totalMonths += curEnd - curStart + 1;
610
972
  return Number((totalMonths / 12).toFixed(2));
611
973
  }
612
- const months = ranges.reduce((total, r) => total + (r.durationInMonths ?? 0), 0);
974
+ const months = ranges.reduce(
975
+ (total, r) => total + (r.durationInMonths ?? 0),
976
+ 0
977
+ );
613
978
  return Number((months / 12).toFixed(2));
614
979
  }
980
+ function detectEmploymentGaps(entries, minGapMonths = 3) {
981
+ const toIndex = (year, month) => year * 12 + month;
982
+ const intervals = entries.filter(
983
+ (e) => e.dates?.startYear !== void 0 && e.dates?.endYear !== void 0
984
+ ).map((e) => ({
985
+ s: toIndex(e.dates.startYear, e.dates.startMonth ?? 1),
986
+ e: toIndex(e.dates.endYear, e.dates.endMonth ?? 12),
987
+ label: e.title || e.company || "previous role"
988
+ })).sort((a, b) => a.s - b.s || b.e - a.e);
989
+ if (intervals.length < 2) {
990
+ return [];
991
+ }
992
+ const gaps = [];
993
+ let curEnd = intervals[0].e;
994
+ let curEndLabel = intervals[0].label;
995
+ for (let i = 1; i < intervals.length; i++) {
996
+ const interval = intervals[i];
997
+ if (interval.s <= curEnd + 1) {
998
+ if (interval.e > curEnd) {
999
+ curEnd = interval.e;
1000
+ curEndLabel = interval.label;
1001
+ }
1002
+ continue;
1003
+ }
1004
+ const gapMonths = interval.s - curEnd - 1;
1005
+ if (gapMonths >= minGapMonths) {
1006
+ gaps.push({ afterRole: curEndLabel, months: gapMonths });
1007
+ }
1008
+ curEnd = interval.e;
1009
+ curEndLabel = interval.label;
1010
+ }
1011
+ return gaps;
1012
+ }
615
1013
 
616
1014
  // src/core/parser/resume.parser.ts
617
1015
  var SECTION_ALIASES = {
618
- summary: ["summary", "profile", "about", "zusammenfassung", "profil", "r\xE9sum\xE9", "\xE0 propos"],
1016
+ summary: [
1017
+ "summary",
1018
+ "profile",
1019
+ "about",
1020
+ "zusammenfassung",
1021
+ "profil",
1022
+ "r\xE9sum\xE9",
1023
+ "\xE0 propos"
1024
+ ],
619
1025
  experience: [
620
1026
  "experience",
621
1027
  "work experience",
622
1028
  "professional experience",
623
1029
  "employment",
1030
+ "work history",
1031
+ "employment history",
624
1032
  "erfahrung",
625
1033
  "berufserfahrung",
626
1034
  "exp\xE9rience",
627
1035
  "exp\xE9rience professionnelle"
628
1036
  ],
629
- skills: ["skills", "technical skills", "technologies", "f\xE4higkeiten", "kenntnisse", "comp\xE9tences"],
630
- education: ["education", "academics", "academic background", "ausbildung", "formation", "\xE9tudes"],
1037
+ skills: [
1038
+ "skills",
1039
+ "technical skills",
1040
+ "technologies",
1041
+ "f\xE4higkeiten",
1042
+ "kenntnisse",
1043
+ "comp\xE9tences"
1044
+ ],
1045
+ education: [
1046
+ "education",
1047
+ "academics",
1048
+ "academic background",
1049
+ "ausbildung",
1050
+ "formation",
1051
+ "\xE9tudes"
1052
+ ],
631
1053
  projects: ["projects", "portfolio", "projekte", "projets"],
632
- certifications: ["certifications", "licenses", "zertifizierungen", "certifications professionnelles"]
1054
+ certifications: [
1055
+ "certifications",
1056
+ "licenses",
1057
+ "zertifizierungen",
1058
+ "certifications professionnelles"
1059
+ ]
633
1060
  };
634
1061
  var STRONG_VERBS = [
635
1062
  "led",
@@ -652,28 +1079,90 @@ var STRONG_VERBS = [
652
1079
  "reduced",
653
1080
  "increased"
654
1081
  ];
655
- var WEAK_VERBS = ["worked", "helped", "performed", "responsible", "assisted", "participated", "involved"];
1082
+ var WEAK_VERBS = [
1083
+ "worked",
1084
+ "helped",
1085
+ "performed",
1086
+ "responsible",
1087
+ "assisted",
1088
+ "participated",
1089
+ "involved"
1090
+ ];
1091
+ var TITLE_SENIORITY_PREFIXES = [
1092
+ "senior",
1093
+ "lead",
1094
+ "principal",
1095
+ "staff",
1096
+ "vp",
1097
+ "director"
1098
+ ];
1099
+ var TITLE_MODIFIER_WORDS = [
1100
+ "software",
1101
+ "full\\s*stack",
1102
+ "frontend",
1103
+ "backend"
1104
+ ];
1105
+ var TITLE_PREFIX_WORDS = unique([
1106
+ ...TITLE_SENIORITY_PREFIXES,
1107
+ ...TITLE_MODIFIER_WORDS,
1108
+ ...ROLE_NOUNS
1109
+ ]);
1110
+ var TITLE_RE = new RegExp(`^(${TITLE_PREFIX_WORDS.join("|")})[^,(-]*`, "i");
656
1111
  var METRIC_RE = /\d|%|\$|\bk\+|\bm\+/i;
657
1112
  function classifyAchievement(line) {
658
1113
  const lower = line.toLowerCase();
659
1114
  const hasMetric = METRIC_RE.test(line);
660
- const hasStrongVerb = STRONG_VERBS.some((verb) => new RegExp(`\\b${verb}\\b`).test(lower));
661
- const hasWeakVerb = WEAK_VERBS.some((verb) => new RegExp(`\\b${verb}\\b`).test(lower));
1115
+ const hasStrongVerb = STRONG_VERBS.some(
1116
+ (verb) => new RegExp(`\\b${verb}\\b`).test(lower)
1117
+ );
1118
+ const hasWeakVerb = WEAK_VERBS.some(
1119
+ (verb) => new RegExp(`\\b${verb}\\b`).test(lower)
1120
+ );
662
1121
  if (hasStrongVerb && hasMetric) {
663
- return { text: line, strength: "strong", reason: "strong verb + quantified impact" };
1122
+ return {
1123
+ text: line,
1124
+ strength: "strong",
1125
+ reason: "strong verb + quantified impact"
1126
+ };
664
1127
  }
665
1128
  if (hasWeakVerb) {
666
1129
  return { text: line, strength: "weak", reason: "weak verb" };
667
1130
  }
668
1131
  return { text: line, strength: "weak", reason: "no quantified impact" };
669
1132
  }
1133
+ var HEADER_TAIL_RE = /^[\s:.\-–—,]*(?:\d{4}\s*(?:[-–—]|to)\s*(?:\d{4}|present|current|now)|\d{4})?[\s:.\-–—,]*$/i;
1134
+ var MAX_HEADER_LINE_LENGTH = 60;
670
1135
  function detectSection(line) {
671
- const normalized = line.trim().toLowerCase();
1136
+ const trimmed = line.trim();
1137
+ if (!trimmed || trimmed.length > MAX_HEADER_LINE_LENGTH) return null;
1138
+ const normalized = trimmed.toLowerCase();
672
1139
  for (const [section, aliases] of Object.entries(SECTION_ALIASES)) {
673
1140
  for (const alias of aliases) {
674
1141
  const safeAlias = escapeRegExp(alias);
675
- const headerPattern = new RegExp(`^${safeAlias}(\\s*:)?$`, "i");
676
- if (headerPattern.test(normalized)) {
1142
+ const headerPattern = new RegExp(`^${safeAlias}(.*)$`, "i");
1143
+ const match = normalized.match(headerPattern);
1144
+ if (match && HEADER_TAIL_RE.test(match[1])) {
1145
+ return section;
1146
+ }
1147
+ }
1148
+ }
1149
+ const coreLine = normalized.replace(/[\s:.\-–—,]+$/, "");
1150
+ const lineWords = coreLine.split(/\s+/).filter(Boolean);
1151
+ for (const [section, aliases] of Object.entries(SECTION_ALIASES)) {
1152
+ for (const alias of aliases) {
1153
+ const aliasWords = alias.split(/\s+/);
1154
+ if (lineWords.length < aliasWords.length) continue;
1155
+ const candidateWords = lineWords.slice(0, aliasWords.length);
1156
+ const allFuzzy = aliasWords.every(
1157
+ (aliasWord, i) => aliasWord !== candidateWords[i] && fuzzyEqual(stem(aliasWord), stem(candidateWords[i]))
1158
+ );
1159
+ if (!allFuzzy) continue;
1160
+ const prefixPattern = new RegExp(
1161
+ `^\\s*${candidateWords.map((w) => escapeRegExp(w)).join("\\s+")}(.*)$`,
1162
+ "i"
1163
+ );
1164
+ const prefixMatch = normalized.match(prefixPattern);
1165
+ if (prefixMatch && HEADER_TAIL_RE.test(prefixMatch[1])) {
677
1166
  return section;
678
1167
  }
679
1168
  }
@@ -705,12 +1194,42 @@ function extractSections(text) {
705
1194
  flush();
706
1195
  return { sections, detected: unique(detected) };
707
1196
  }
708
- function parseSkills(sectionContent, aliases) {
1197
+ function parseExplicitSkillList(sectionContent) {
709
1198
  if (!sectionContent) return [];
710
1199
  const hasBullets = /[•·‣▪○●◦]/.test(sectionContent);
711
1200
  const normalized = hasBullets ? sectionContent.replace(/\n/g, " ") : sectionContent;
712
- const raw = normalized.split(/[,;\n]|[•·‣▪○●◦]/).map((skill) => skill.trim().replace(/^[-•·‣▪○●◦\s]+|[-•·‣▪○●◦\s]+$/g, "").trim()).filter(Boolean);
713
- return normalizeSkills(raw, aliases);
1201
+ return normalized.split(/[,;\n]|[•·‣▪○●◦]/).map(
1202
+ (skill) => skill.trim().replace(/^[-•·‣▪○●◦\s]+|[-•·‣▪○●◦\s]+$/g, "").trim()
1203
+ ).filter(Boolean);
1204
+ }
1205
+ function extractKnownSkillsFromText(text, aliases) {
1206
+ if (!text) return [];
1207
+ const canonicalSet = new Set(
1208
+ Object.keys(aliases).map((c) => c.toLowerCase())
1209
+ );
1210
+ const found = [];
1211
+ for (const token of tokenize(text)) {
1212
+ const canonical = normalizeSkill(token, aliases);
1213
+ if (canonicalSet.has(canonical)) {
1214
+ found.push(canonical);
1215
+ }
1216
+ }
1217
+ return unique(found);
1218
+ }
1219
+ function parseSkills(sections, aliases) {
1220
+ const explicit = normalizeSkills(
1221
+ parseExplicitSkillList(sections.skills),
1222
+ aliases
1223
+ );
1224
+ const fromExperience = extractKnownSkillsFromText(
1225
+ sections.experience,
1226
+ aliases
1227
+ );
1228
+ const fromSummary = extractKnownSkillsFromText(sections.summary, aliases);
1229
+ return normalizeSkills(
1230
+ unique([...explicit, ...fromExperience, ...fromSummary]),
1231
+ aliases
1232
+ );
714
1233
  }
715
1234
  function parseActionVerbs(text) {
716
1235
  const words = tokenize(text);
@@ -719,7 +1238,7 @@ function parseActionVerbs(text) {
719
1238
  weak: WEAK_VERBS.filter((verb) => words.includes(verb))
720
1239
  };
721
1240
  }
722
- function parseExperience(sectionContent, referenceDate) {
1241
+ function parseExperience(sectionContent, referenceDate, aliases) {
723
1242
  if (!sectionContent) {
724
1243
  return { entries: [], rangesInMonths: [], jobTitles: [], achievements: [] };
725
1244
  }
@@ -730,23 +1249,32 @@ function parseExperience(sectionContent, referenceDate) {
730
1249
  const achievements = [];
731
1250
  for (const line of lines) {
732
1251
  const range = parseDateRange(line, referenceDate);
1252
+ const titleMatch2 = line.match(TITLE_RE);
1253
+ const title = titleMatch2 ? titleMatch2[0].trim() : void 0;
733
1254
  if (range) {
734
- const previous = entries[entries.length - 1];
735
- if (previous && !previous.dates) {
736
- previous.dates = range;
1255
+ if (title) {
1256
+ jobTitles.push(title.toLowerCase());
1257
+ entries.push({ title, dates: range, description: line, skills: [] });
737
1258
  } else {
738
- entries.push({ dates: range });
1259
+ const previous = entries[entries.length - 1];
1260
+ if (previous && !previous.dates) {
1261
+ previous.dates = range;
1262
+ } else {
1263
+ entries.push({ dates: range, skills: [] });
1264
+ }
739
1265
  }
740
1266
  if (range.durationInMonths) {
741
1267
  rangesInMonths.push(range.durationInMonths);
742
1268
  }
743
1269
  continue;
744
1270
  }
745
- const titleMatch = line.match(/^(Senior|Lead|Principal|Staff|VP|Director|Consultant|Architect|Software|Full\s*Stack|Frontend|Backend|Engineer|Developer|Manager|Analyst)[^,-]*/i);
746
- if (titleMatch) {
747
- const title = titleMatch[0].trim();
1271
+ if (title) {
748
1272
  jobTitles.push(title.toLowerCase());
749
- const entry = { title, description: line };
1273
+ const entry = {
1274
+ title,
1275
+ description: line,
1276
+ skills: []
1277
+ };
750
1278
  entries.push(entry);
751
1279
  achievements.push(classifyAchievement(line));
752
1280
  continue;
@@ -757,7 +1285,15 @@ function parseExperience(sectionContent, referenceDate) {
757
1285
  }
758
1286
  achievements.push(classifyAchievement(line));
759
1287
  }
760
- return { entries, rangesInMonths, jobTitles: unique(jobTitles), achievements };
1288
+ for (const entry of entries) {
1289
+ entry.skills = extractKnownSkillsFromText(entry.description, aliases);
1290
+ }
1291
+ return {
1292
+ entries,
1293
+ rangesInMonths,
1294
+ jobTitles: unique(jobTitles),
1295
+ achievements
1296
+ };
761
1297
  }
762
1298
  function parseEducation(sectionContent) {
763
1299
  if (!sectionContent) return [];
@@ -766,12 +1302,38 @@ function parseEducation(sectionContent) {
766
1302
  function collectKeywords(text) {
767
1303
  return unique(tokenize(text));
768
1304
  }
1305
+ var EMAIL_RE = /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/i;
1306
+ var PHONE_RE = /\+?\(?\d{1,4}\)?(?:[\s.-]?\d{2,4}){2,4}/;
1307
+ var LINKEDIN_RE = /(?:https?:\/\/)?(?:[a-z]{2,3}\.)?linkedin\.com\/(?:in|pub)\/[a-z0-9\-_%]+\/?/i;
1308
+ var LOCATION_RE = /^[A-Z][A-Za-z.'-]+(?:\s[A-Z][A-Za-z.'-]+)*,\s*[A-Z][A-Za-z]{1,20}$/;
1309
+ function extractLocation(text) {
1310
+ const candidateLines = splitLines(text).slice(0, 8);
1311
+ for (const line of candidateLines) {
1312
+ if (EMAIL_RE.test(line) || /\d/.test(line)) continue;
1313
+ if (LOCATION_RE.test(line)) {
1314
+ return line;
1315
+ }
1316
+ }
1317
+ return void 0;
1318
+ }
1319
+ function extractContact(text) {
1320
+ const email = text.match(EMAIL_RE)?.[0];
1321
+ const phone = text.match(PHONE_RE)?.[0]?.trim();
1322
+ const linkedin = text.match(LINKEDIN_RE)?.[0];
1323
+ const location = extractLocation(text);
1324
+ if (!email && !phone && !linkedin && !location) return void 0;
1325
+ return { email, phone, linkedin, location };
1326
+ }
769
1327
  function parseResume(resumeText, config) {
770
1328
  const normalizedText = normalizeWhitespace(resumeText);
771
1329
  const { sections, detected } = extractSections(resumeText);
772
- const skills = parseSkills(sections.skills, config.skillAliases);
1330
+ const skills = parseSkills(sections, config.skillAliases);
773
1331
  const actionVerbs = parseActionVerbs(normalizedText);
774
- const experienceData = parseExperience(sections.experience, config.referenceDate);
1332
+ const experienceData = parseExperience(
1333
+ sections.experience,
1334
+ config.referenceDate,
1335
+ config.skillAliases
1336
+ );
775
1337
  const educationEntries = parseEducation(sections.education);
776
1338
  let totalExperienceYears = sumExperienceYears(
777
1339
  experienceData.entries.map((entry) => entry.dates).filter((range) => Boolean(range))
@@ -784,7 +1346,12 @@ function parseResume(resumeText, config) {
784
1346
  totalExperienceYears = parsed <= 60 ? parsed : 0;
785
1347
  }
786
1348
  }
787
- const requiredSections = ["summary", "experience", "skills", "education"];
1349
+ const requiredSections = [
1350
+ "summary",
1351
+ "experience",
1352
+ "skills",
1353
+ "education"
1354
+ ];
788
1355
  const warnings = [];
789
1356
  const lineCount = splitLines(resumeText).length;
790
1357
  if (resumeText.trim().length < 100) {
@@ -801,6 +1368,12 @@ function parseResume(resumeText, config) {
801
1368
  warnings.push(`${section} section not detected`);
802
1369
  }
803
1370
  }
1371
+ const contact = extractContact(resumeText);
1372
+ if (!contact?.email) {
1373
+ warnings.push(
1374
+ "No email address detected \u2014 most ATS require a parseable contact email"
1375
+ );
1376
+ }
804
1377
  return {
805
1378
  raw: resumeText,
806
1379
  normalizedText,
@@ -816,6 +1389,10 @@ function parseResume(resumeText, config) {
816
1389
  totalExperienceYears,
817
1390
  keywords: collectKeywords(normalizedText),
818
1391
  languages: parseLanguageMentions(resumeText),
1392
+ contact,
1393
+ seniority: inferSeniority(experienceData.jobTitles),
1394
+ employmentGaps: detectEmploymentGaps(experienceData.entries),
1395
+ formatting: detectFormatting(resumeText),
819
1396
  warnings
820
1397
  };
821
1398
  }
@@ -826,7 +1403,12 @@ var RuleEngine = class {
826
1403
  this.config = config;
827
1404
  }
828
1405
  applySectionPenalties(resume) {
829
- const requiredSections = ["summary", "experience", "skills", "education"];
1406
+ const requiredSections = [
1407
+ "summary",
1408
+ "experience",
1409
+ "skills",
1410
+ "education"
1411
+ ];
830
1412
  let totalPenalty = 0;
831
1413
  const warnings = [];
832
1414
  const penaltyBySection = {
@@ -852,19 +1434,26 @@ var RuleEngine = class {
852
1434
  const sectionResult = this.applySectionPenalties(input.resume);
853
1435
  totalPenalty += sectionResult.totalPenalty;
854
1436
  warnings.push(...sectionResult.warnings);
855
- if (containsTableLikeStructure(input.resume.raw)) {
1437
+ if (input.resume.formatting.hasTables) {
856
1438
  totalPenalty += 8;
857
1439
  warnings.push("Detected table-like or columnar formatting (penalty 8)");
858
1440
  }
859
1441
  if (input.overusedKeywords && input.overusedKeywords.length > 0) {
860
1442
  const penalty = input.overusedKeywords.length * this.config.keywordDensity.overusePenalty;
861
1443
  totalPenalty += penalty;
862
- warnings.push(`Keyword stuffing detected for: ${input.overusedKeywords.join(", ")} (penalty ${penalty})`);
1444
+ warnings.push(
1445
+ `Keyword stuffing detected for: ${input.overusedKeywords.join(", ")} (penalty ${penalty})`
1446
+ );
863
1447
  }
864
1448
  if (input.resume.detectedSections.length < 3) {
865
1449
  totalPenalty += 5;
866
1450
  warnings.push("Few recognizable sections found (penalty 5)");
867
1451
  }
1452
+ if (!input.resume.contact?.email) {
1453
+ const penalty = this.config.sectionPenalties.missingContact;
1454
+ totalPenalty += penalty;
1455
+ warnings.push(`Missing contact email (penalty ${penalty})`);
1456
+ }
868
1457
  return { totalPenalty, warnings };
869
1458
  }
870
1459
  evaluate(input) {
@@ -895,9 +1484,31 @@ var RuleEngine = class {
895
1484
  // src/core/scoring/scorer.ts
896
1485
  var REQUIRED_SKILL_WEIGHT = 0.7;
897
1486
  var OPTIONAL_SKILL_WEIGHT = 0.3;
898
- var EXPERIENCE_YEARS_WEIGHT = 0.75;
899
- var EXPERIENCE_ROLE_WEIGHT = 0.25;
900
- var ALL_CATEGORIES = ["technical", "tool", "concept", "soft", "marketing", "domain"];
1487
+ var EXPERIENCE_YEARS_WEIGHT = 0.65;
1488
+ var EXPERIENCE_ROLE_WEIGHT = 0.2;
1489
+ var EXPERIENCE_SENIORITY_WEIGHT = 0.15;
1490
+ var ALL_CATEGORIES = [
1491
+ "technical",
1492
+ "tool",
1493
+ "concept",
1494
+ "soft",
1495
+ "marketing",
1496
+ "domain"
1497
+ ];
1498
+ var SENIORITY_RANK2 = {
1499
+ junior: 0,
1500
+ mid: 1,
1501
+ senior: 2,
1502
+ lead: 3,
1503
+ principal: 4
1504
+ };
1505
+ var DEGREE_RANK = {
1506
+ associate: 1,
1507
+ bachelor: 2,
1508
+ master: 3,
1509
+ mba: 3,
1510
+ phd: 4
1511
+ };
901
1512
  function emptyCategoryBuckets() {
902
1513
  const buckets = {};
903
1514
  for (const category of ALL_CATEGORIES) {
@@ -905,18 +1516,32 @@ function emptyCategoryBuckets() {
905
1516
  }
906
1517
  return buckets;
907
1518
  }
908
- function scoreSkills(resume, job, config) {
1519
+ function scoreSkills(resume, job, config, matchOptions) {
909
1520
  const profileRequired = config.profile?.mandatorySkills ?? [];
910
1521
  const profileOptional = config.profile?.optionalSkills ?? [];
911
1522
  const required = new Set(
912
- normalizeSkills([...job.requiredSkills, ...profileRequired], config.skillAliases)
1523
+ normalizeSkills(
1524
+ [...job.requiredSkills, ...profileRequired],
1525
+ config.skillAliases,
1526
+ matchOptions
1527
+ )
913
1528
  );
914
1529
  const optional = new Set(
915
- normalizeSkills([...job.preferredSkills, ...profileOptional], config.skillAliases)
1530
+ normalizeSkills(
1531
+ [...job.preferredSkills, ...profileOptional],
1532
+ config.skillAliases,
1533
+ matchOptions
1534
+ )
1535
+ );
1536
+ const resumeSkills = new Set(
1537
+ normalizeSkills(resume.skills, config.skillAliases, matchOptions)
1538
+ );
1539
+ const matchedRequired = [...required].filter(
1540
+ (skill) => resumeSkills.has(skill)
1541
+ );
1542
+ const matchedOptional = [...optional].filter(
1543
+ (skill) => resumeSkills.has(skill)
916
1544
  );
917
- const resumeSkills = new Set(normalizeSkills(resume.skills, config.skillAliases));
918
- const matchedRequired = [...required].filter((skill) => resumeSkills.has(skill));
919
- const matchedOptional = [...optional].filter((skill) => resumeSkills.has(skill));
920
1545
  const requiredCoverage = required.size === 0 ? 1 : matchedRequired.length / required.size;
921
1546
  const optionalCoverage = optional.size === 0 ? 1 : matchedOptional.length / optional.size;
922
1547
  const score = clamp(
@@ -928,29 +1553,45 @@ function scoreSkills(resume, job, config) {
928
1553
  const missing = [...required].filter((skill) => !resumeSkills.has(skill)).sort();
929
1554
  return { score, matched, missing };
930
1555
  }
1556
+ function computeSeniorityMatch(resume, job) {
1557
+ const resumeSeniority = resume.seniority;
1558
+ const required = job.seniority;
1559
+ const met = !resumeSeniority || !required || SENIORITY_RANK2[resumeSeniority] >= SENIORITY_RANK2[required];
1560
+ return { resume: resumeSeniority, required, met };
1561
+ }
931
1562
  function scoreExperience(resume, job, config) {
1563
+ const seniorityMatch = computeSeniorityMatch(resume, job);
932
1564
  const requiredYears = job.minExperienceYears ?? config.profile?.minExperience ?? 0;
933
1565
  if (!requiredYears) {
934
- return { score: 100, missingYears: 0 };
1566
+ return { score: 100, missingYears: 0, seniorityMatch };
935
1567
  }
936
1568
  const yearCoverage = clamp(resume.totalExperienceYears / requiredYears, 0, 2);
937
1569
  const yearsComponent = clamp(yearCoverage, 0, 1) * EXPERIENCE_YEARS_WEIGHT;
938
- const jobRoleSet = new Set(job.roleKeywords.map((value) => value.toLowerCase()));
939
- const titleMatches = resume.jobTitles.filter((title) => jobRoleSet.has(title.toLowerCase()));
940
- const titleCoverage = jobRoleSet.size === 0 ? 1 : titleMatches.length / jobRoleSet.size;
1570
+ const titleCoverage = job.roleKeywords.length === 0 ? 1 : titleMatch(resume.jobTitles, job.roleKeywords);
941
1571
  const roleComponent = clamp(titleCoverage, 0, 1) * EXPERIENCE_ROLE_WEIGHT;
942
- const score = clamp((yearsComponent + roleComponent) * 100, 0, 100);
1572
+ const seniorityComponent = (seniorityMatch.met ? 1 : 0) * EXPERIENCE_SENIORITY_WEIGHT;
1573
+ const score = clamp(
1574
+ (yearsComponent + roleComponent + seniorityComponent) * 100,
1575
+ 0,
1576
+ 100
1577
+ );
943
1578
  const missingYears = Math.max(requiredYears - resume.totalExperienceYears, 0);
944
- return { score, missingYears: Number(missingYears.toFixed(2)) };
1579
+ return {
1580
+ score,
1581
+ missingYears: Number(missingYears.toFixed(2)),
1582
+ seniorityMatch
1583
+ };
945
1584
  }
946
1585
  function keywordWeightOf(keyword, requiredSet, preferredSet, jdFrequencies) {
947
1586
  const base = requiredSet.has(keyword) ? 3 : preferredSet.has(keyword) ? 2 : 1;
948
1587
  const freqBonus = Math.min((jdFrequencies[keyword] ?? 1) - 1, 3) * 0.25;
949
1588
  return base + freqBonus;
950
1589
  }
951
- function scoreKeywords(resume, job, config) {
1590
+ function scoreKeywords(resume, job, config, matchOptions) {
952
1591
  const jobKeywordSet = new Set(
953
- job.keywords.map((k) => normalizeSkill(k, config.skillAliases))
1592
+ job.keywords.map(
1593
+ (k) => normalizeSkill(k, config.skillAliases, matchOptions)
1594
+ )
954
1595
  );
955
1596
  if (jobKeywordSet.size === 0) {
956
1597
  return {
@@ -963,20 +1604,32 @@ function scoreKeywords(resume, job, config) {
963
1604
  };
964
1605
  }
965
1606
  const resumeTokens = tokenize(resume.normalizedText).map(
966
- (t) => normalizeSkill(t, config.skillAliases)
1607
+ (t) => normalizeSkill(t, config.skillAliases, matchOptions)
967
1608
  );
968
1609
  const resumeTokenSet = new Set(resumeTokens);
969
1610
  const resumeFrequencies = countFrequencies(resumeTokens);
970
1611
  const requiredSet = new Set(job.requiredSkills);
971
1612
  const preferredSet = new Set(job.preferredSkills);
972
1613
  const jdFrequencies = countFrequencies(
973
- tokenize(job.normalizedText).map((t) => normalizeSkill(t, config.skillAliases))
1614
+ tokenize(job.normalizedText).map(
1615
+ (t) => normalizeSkill(t, config.skillAliases, matchOptions)
1616
+ )
974
1617
  );
975
1618
  const weightOf = (keyword) => keywordWeightOf(keyword, requiredSet, preferredSet, jdFrequencies);
976
- const matchedKeywords = [...jobKeywordSet].filter((keyword) => resumeTokenSet.has(keyword));
977
- const missingKeywords = [...jobKeywordSet].filter((keyword) => !resumeTokenSet.has(keyword));
978
- const totalWeight = [...jobKeywordSet].reduce((sum, keyword) => sum + weightOf(keyword), 0);
979
- const matchedWeight = matchedKeywords.reduce((sum, keyword) => sum + weightOf(keyword), 0);
1619
+ const matchedKeywords = [...jobKeywordSet].filter(
1620
+ (keyword) => resumeTokenSet.has(keyword)
1621
+ );
1622
+ const missingKeywords = [...jobKeywordSet].filter(
1623
+ (keyword) => !resumeTokenSet.has(keyword)
1624
+ );
1625
+ const totalWeight = [...jobKeywordSet].reduce(
1626
+ (sum, keyword) => sum + weightOf(keyword),
1627
+ 0
1628
+ );
1629
+ const matchedWeight = matchedKeywords.reduce(
1630
+ (sum, keyword) => sum + weightOf(keyword),
1631
+ 0
1632
+ );
980
1633
  const score = clamp(matchedWeight / totalWeight * 100, 0, 100);
981
1634
  const totalTokens = resumeTokens.length || 1;
982
1635
  const overusedKeywords = matchedKeywords.filter((keyword) => {
@@ -1013,38 +1666,158 @@ function scoreKeywords(resume, job, config) {
1013
1666
  keywordWeights
1014
1667
  };
1015
1668
  }
1669
+ function computePerSkillExperience(resume, config, matchOptions) {
1670
+ const bySkill = /* @__PURE__ */ new Map();
1671
+ for (const entry of resume.experience) {
1672
+ if (!entry.dates || entry.skills.length === 0) continue;
1673
+ const skillsInRole = normalizeSkills(
1674
+ entry.skills,
1675
+ config.skillAliases,
1676
+ matchOptions
1677
+ );
1678
+ for (const skill of skillsInRole) {
1679
+ const list = bySkill.get(skill) ?? [];
1680
+ list.push(entry.dates);
1681
+ bySkill.set(skill, list);
1682
+ }
1683
+ }
1684
+ return [...bySkill.entries()].map(([skill, dates]) => ({ skill, years: sumExperienceYears(dates) })).sort((a, b) => a.skill.localeCompare(b.skill));
1685
+ }
1686
+ function computeSkillExperienceGaps(resume, job, config, matchOptions, perSkillExperience) {
1687
+ if (job.skillExperienceRequirements.length === 0) return [];
1688
+ const resumeSkills = new Set(
1689
+ normalizeSkills(resume.skills, config.skillAliases, matchOptions)
1690
+ );
1691
+ const yearsBySkill = new Map(
1692
+ perSkillExperience.map((p) => [p.skill, p.years])
1693
+ );
1694
+ const gaps = [];
1695
+ for (const { skill, years } of job.skillExperienceRequirements) {
1696
+ if (!resumeSkills.has(skill)) continue;
1697
+ const resumeYears = yearsBySkill.get(skill) ?? 0;
1698
+ if (resumeYears < years) {
1699
+ gaps.push({ skill, requiredYears: years, resumeYears });
1700
+ }
1701
+ }
1702
+ return gaps.sort((a, b) => a.skill.localeCompare(b.skill));
1703
+ }
1016
1704
  function scoreEducation(resume, job) {
1017
1705
  if (job.educationRequirements.length === 0) {
1018
1706
  return 100;
1019
1707
  }
1020
- const resumeDegreeLevels = extractDegreeLevels(resume.educationEntries.join(" "));
1021
- const matched = job.educationRequirements.filter(
1022
- (requirement) => resumeDegreeLevels.includes(requirement)
1708
+ const resumeDegreeLevels = extractDegreeLevels(
1709
+ resume.educationEntries.join(" ")
1023
1710
  );
1024
- if (matched.length === 0) {
1711
+ if (resumeDegreeLevels.length === 0) {
1025
1712
  return 0;
1026
1713
  }
1027
- return clamp(matched.length / job.educationRequirements.length * 100, 0, 100);
1714
+ const resumeMaxRank = Math.max(
1715
+ ...resumeDegreeLevels.map((d) => DEGREE_RANK[d] ?? 0)
1716
+ );
1717
+ const perRequirementCredit = job.educationRequirements.map(
1718
+ (requirement) => {
1719
+ if (resumeDegreeLevels.includes(requirement)) return 1;
1720
+ const requiredRank = DEGREE_RANK[requirement] ?? 0;
1721
+ const diff = resumeMaxRank - requiredRank;
1722
+ if (diff >= 1) return 0.85;
1723
+ if (diff === 0) return 0.6;
1724
+ if (diff === -1) return 0.35;
1725
+ return 0;
1726
+ }
1727
+ );
1728
+ const avgCredit = perRequirementCredit.reduce((sum, credit) => sum + credit, 0) / perRequirementCredit.length;
1729
+ return clamp(avgCredit * 100, 0, 100);
1730
+ }
1731
+ function scoreParseability(formatting, contact, detectedSections) {
1732
+ let score = 100;
1733
+ const deductions = [];
1734
+ const deduct = (points, reason) => {
1735
+ score -= points;
1736
+ deductions.push({ reason, points });
1737
+ };
1738
+ if (formatting.hasTables) {
1739
+ deduct(
1740
+ 20,
1741
+ "Table or columnar formatting detected \u2014 ATS parsers often misalign fields extracted from tables"
1742
+ );
1743
+ }
1744
+ if (formatting.hasMultiColumn) {
1745
+ deduct(
1746
+ 20,
1747
+ "Multi-column layout detected \u2014 text may extract out of reading order in real ATS systems"
1748
+ );
1749
+ }
1750
+ if (formatting.hasSpecialChars) {
1751
+ deduct(
1752
+ 10,
1753
+ "Special or control characters detected \u2014 likely icon fonts or a lossy text extraction"
1754
+ );
1755
+ }
1756
+ if (formatting.nonStandardBullets) {
1757
+ deduct(
1758
+ 8,
1759
+ "Non-standard bullet characters detected \u2014 some ATS parsers won't recognize them as list items"
1760
+ );
1761
+ }
1762
+ if (formatting.likelyScanned) {
1763
+ deduct(
1764
+ 30,
1765
+ "Resume text looks scanned or image-based with little extractable text"
1766
+ );
1767
+ }
1768
+ if (!contact?.email || !formatting.contactParseable) {
1769
+ deduct(15, "No parseable contact email detected");
1770
+ }
1771
+ if (detectedSections.length < 3) {
1772
+ deduct(10, "Fewer than 3 standard resume sections were detected");
1773
+ }
1774
+ return {
1775
+ score: clamp(score, 0, 100),
1776
+ report: {
1777
+ ...formatting,
1778
+ detectedSectionCount: detectedSections.length,
1779
+ deductions
1780
+ }
1781
+ };
1028
1782
  }
1029
1783
  function calculateScore(resume, job, config) {
1030
- const skillsResult = scoreSkills(resume, job, config);
1784
+ const matchOptions = {
1785
+ fuzzy: config.matching.fuzzy,
1786
+ maxDistance: config.matching.threshold
1787
+ };
1788
+ const skillsResult = scoreSkills(resume, job, config, matchOptions);
1031
1789
  const experienceResult = scoreExperience(resume, job, config);
1032
- const keywordResult = scoreKeywords(resume, job, config);
1790
+ const keywordResult = scoreKeywords(resume, job, config, matchOptions);
1033
1791
  const educationScore = scoreEducation(resume, job);
1792
+ const parseabilityResult = scoreParseability(
1793
+ resume.formatting,
1794
+ resume.contact,
1795
+ resume.detectedSections
1796
+ );
1034
1797
  const breakdown = {
1035
1798
  skills: skillsResult.score,
1036
1799
  experience: experienceResult.score,
1037
1800
  keywords: keywordResult.score,
1801
+ parseability: parseabilityResult.score,
1038
1802
  education: educationScore
1039
1803
  };
1040
- const weightedScore = breakdown.skills * config.weights.skills + breakdown.experience * config.weights.experience + breakdown.keywords * config.weights.keywords + breakdown.education * config.weights.education;
1804
+ const weightedScore = breakdown.skills * config.weights.skills + breakdown.experience * config.weights.experience + breakdown.keywords * config.weights.keywords + breakdown.parseability * config.weights.parseability + breakdown.education * config.weights.education;
1041
1805
  const achievementStrength = {
1042
1806
  strong: resume.achievements.filter((a) => a.strength === "strong").length,
1043
1807
  weak: resume.achievements.filter((a) => a.strength === "weak").length
1044
1808
  };
1045
- const { matched: matchedLanguages, missing: missingLanguages } = diffLanguages(
1046
- resume.languages,
1047
- job.requiredLanguages
1809
+ const { matched: matchedLanguages, missing: missingLanguages } = diffLanguages(resume.languages, job.requiredLanguages);
1810
+ const perSkillExperience = computePerSkillExperience(
1811
+ resume,
1812
+ config,
1813
+ matchOptions
1814
+ );
1815
+ const skillExperienceGaps = computeSkillExperienceGaps(
1816
+ resume,
1817
+ job,
1818
+ config,
1819
+ matchOptions,
1820
+ perSkillExperience
1048
1821
  );
1049
1822
  return {
1050
1823
  score: clamp(Number(weightedScore.toFixed(2)), 0, 100),
@@ -1059,6 +1832,7 @@ function calculateScore(resume, job, config) {
1059
1832
  achievementStrength,
1060
1833
  matchedLanguages,
1061
1834
  missingLanguages,
1835
+ skillExperienceGaps,
1062
1836
  suggestions: [],
1063
1837
  warnings: [],
1064
1838
  // detectedSections / parsedExperienceYears / experienceGap / experienceEntries: filled by index.ts
@@ -1067,7 +1841,11 @@ function calculateScore(resume, job, config) {
1067
1841
  parsedExperienceYears: 0,
1068
1842
  experienceEntries: [],
1069
1843
  missingExperienceYears: experienceResult.missingYears,
1070
- educationScore
1844
+ educationScore,
1845
+ parseabilityReport: parseabilityResult.report,
1846
+ employmentGaps: resume.employmentGaps,
1847
+ seniorityMatch: experienceResult.seniorityMatch,
1848
+ perSkillExperience
1071
1849
  };
1072
1850
  }
1073
1851
 
@@ -1075,109 +1853,499 @@ function calculateScore(resume, job, config) {
1075
1853
  var defaultKeywordRegistry = [
1076
1854
  // languages / frameworks
1077
1855
  // ponytail: "node" split from javascript — Node.js runtime !== JS language
1078
- { canonical: "javascript", aliases: ["js"], category: "technical" },
1856
+ {
1857
+ canonical: "javascript",
1858
+ aliases: ["js", "ecmascript", "es6"],
1859
+ category: "technical"
1860
+ },
1079
1861
  { canonical: "node", aliases: ["node.js", "nodejs"], category: "technical" },
1080
1862
  { canonical: "typescript", aliases: ["ts"], category: "technical" },
1081
- { canonical: "react", aliases: ["reactjs", "react.js"], category: "technical" },
1082
- { canonical: "angular", aliases: ["angularjs"], category: "technical" },
1863
+ {
1864
+ canonical: "react",
1865
+ aliases: ["reactjs", "react.js"],
1866
+ category: "technical"
1867
+ },
1868
+ {
1869
+ canonical: "angular",
1870
+ aliases: ["angularjs", "angular2+"],
1871
+ category: "technical"
1872
+ },
1083
1873
  { canonical: "vue", aliases: ["vue.js", "vuejs"], category: "technical" },
1084
- { canonical: "svelte", aliases: [], category: "technical" },
1874
+ { canonical: "svelte", aliases: ["sveltekit"], category: "technical" },
1085
1875
  { canonical: "next.js", aliases: ["nextjs"], category: "technical" },
1876
+ { canonical: "nuxt.js", aliases: ["nuxtjs", "nuxt"], category: "technical" },
1877
+ { canonical: "remix", aliases: [], category: "technical" },
1086
1878
  { canonical: "c++", aliases: ["cpp"], category: "technical" },
1087
1879
  { canonical: "c#", aliases: ["csharp", ".net"], category: "technical" },
1088
1880
  { canonical: "java", aliases: [], category: "technical" },
1089
1881
  { canonical: "python", aliases: ["py"], category: "technical" },
1090
1882
  { canonical: "go", aliases: ["golang"], category: "technical" },
1091
1883
  { canonical: "rust", aliases: [], category: "technical" },
1092
- { canonical: "ruby", aliases: ["ruby on rails", "rails"], category: "technical" },
1884
+ {
1885
+ canonical: "ruby",
1886
+ aliases: ["ruby on rails", "rails"],
1887
+ category: "technical"
1888
+ },
1093
1889
  { canonical: "php", aliases: [], category: "technical" },
1094
- { canonical: "swift", aliases: [], category: "technical" },
1890
+ { canonical: "swift", aliases: ["swiftui"], category: "technical" },
1095
1891
  { canonical: "kotlin", aliases: [], category: "technical" },
1096
1892
  { canonical: "scala", aliases: [], category: "technical" },
1893
+ { canonical: "perl", aliases: [], category: "technical" },
1894
+ {
1895
+ canonical: "r",
1896
+ aliases: ["r language", "r programming"],
1897
+ category: "technical"
1898
+ },
1899
+ { canonical: "matlab", aliases: [], category: "technical" },
1900
+ {
1901
+ canonical: "objective-c",
1902
+ aliases: ["objective c", "objc"],
1903
+ category: "technical"
1904
+ },
1905
+ { canonical: "dart", aliases: [], category: "technical" },
1906
+ { canonical: "elixir", aliases: [], category: "technical" },
1907
+ { canonical: "haskell", aliases: [], category: "technical" },
1097
1908
  { canonical: "html", aliases: ["html5"], category: "technical" },
1098
1909
  { canonical: "css", aliases: ["css3"], category: "technical" },
1910
+ { canonical: "sass", aliases: ["scss"], category: "technical" },
1911
+ { canonical: "less", aliases: [], category: "technical" },
1912
+ {
1913
+ canonical: "tailwind css",
1914
+ aliases: ["tailwind", "tailwindcss"],
1915
+ category: "technical"
1916
+ },
1917
+ { canonical: "bootstrap", aliases: [], category: "technical" },
1918
+ { canonical: "webassembly", aliases: ["wasm"], category: "technical" },
1099
1919
  { canonical: "ios development", aliases: ["ios"], category: "technical" },
1100
- { canonical: "android development", aliases: ["android"], category: "technical" },
1920
+ {
1921
+ canonical: "android development",
1922
+ aliases: ["android"],
1923
+ category: "technical"
1924
+ },
1101
1925
  { canonical: "react native", aliases: [], category: "technical" },
1102
1926
  { canonical: "flutter", aliases: [], category: "technical" },
1927
+ { canonical: "xamarin", aliases: [], category: "technical" },
1103
1928
  { canonical: "machine learning", aliases: ["ml"], category: "technical" },
1104
- { canonical: "deep learning", aliases: [], category: "technical" },
1105
- { canonical: "natural language processing", aliases: ["nlp"], category: "technical" },
1929
+ { canonical: "deep learning", aliases: ["dl"], category: "technical" },
1930
+ {
1931
+ canonical: "natural language processing",
1932
+ aliases: ["nlp"],
1933
+ category: "technical"
1934
+ },
1935
+ { canonical: "computer vision", aliases: ["cv"], category: "technical" },
1936
+ {
1937
+ canonical: "large language models",
1938
+ aliases: ["llm", "llms"],
1939
+ category: "technical"
1940
+ },
1941
+ {
1942
+ canonical: "generative ai",
1943
+ aliases: ["genai", "gen ai"],
1944
+ category: "technical"
1945
+ },
1946
+ {
1947
+ canonical: "reinforcement learning",
1948
+ aliases: ["rl"],
1949
+ category: "technical"
1950
+ },
1951
+ {
1952
+ canonical: "neural networks",
1953
+ aliases: ["neural network"],
1954
+ category: "technical"
1955
+ },
1956
+ { canonical: "data engineering", aliases: [], category: "technical" },
1957
+ {
1958
+ canonical: "data warehousing",
1959
+ aliases: ["data warehouse"],
1960
+ category: "technical"
1961
+ },
1962
+ {
1963
+ canonical: "etl",
1964
+ aliases: ["extract transform load", "elt"],
1965
+ category: "technical"
1966
+ },
1967
+ { canonical: "distributed systems", aliases: [], category: "technical" },
1968
+ { canonical: "embedded systems", aliases: [], category: "technical" },
1969
+ {
1970
+ canonical: "firmware development",
1971
+ aliases: ["firmware"],
1972
+ category: "technical"
1973
+ },
1974
+ { canonical: "game development", aliases: [], category: "technical" },
1975
+ { canonical: "unity", aliases: [], category: "technical" },
1976
+ { canonical: "unreal engine", aliases: ["unreal"], category: "technical" },
1977
+ { canonical: "web development", aliases: [], category: "technical" },
1978
+ {
1979
+ canonical: "full stack development",
1980
+ aliases: ["full-stack development", "full stack"],
1981
+ category: "technical"
1982
+ },
1983
+ { canonical: "api development", aliases: [], category: "technical" },
1984
+ { canonical: "grpc", aliases: [], category: "technical" },
1985
+ { canonical: "websockets", aliases: ["websocket"], category: "technical" },
1986
+ { canonical: "linux", aliases: [], category: "technical" },
1987
+ { canonical: "unix", aliases: [], category: "technical" },
1988
+ {
1989
+ canonical: "bash scripting",
1990
+ aliases: ["bash", "shell scripting"],
1991
+ category: "technical"
1992
+ },
1993
+ { canonical: "powershell", aliases: [], category: "technical" },
1106
1994
  // tools / platforms / infra
1107
- { canonical: "sql", aliases: ["postgres", "mysql", "sqlite"], category: "tool" },
1995
+ {
1996
+ canonical: "sql",
1997
+ aliases: ["structured query language"],
1998
+ category: "tool"
1999
+ },
2000
+ { canonical: "postgresql", aliases: ["postgres", "psql"], category: "tool" },
2001
+ { canonical: "mysql", aliases: [], category: "tool" },
2002
+ { canonical: "sqlite", aliases: [], category: "tool" },
2003
+ {
2004
+ canonical: "microsoft sql server",
2005
+ aliases: ["mssql", "sql server", "t-sql"],
2006
+ category: "tool"
2007
+ },
2008
+ {
2009
+ canonical: "oracle database",
2010
+ aliases: ["oracle db", "oracle"],
2011
+ category: "tool"
2012
+ },
2013
+ { canonical: "mongodb", aliases: ["mongo"], category: "tool" },
2014
+ { canonical: "cassandra", aliases: ["apache cassandra"], category: "tool" },
2015
+ { canonical: "dynamodb", aliases: ["amazon dynamodb"], category: "tool" },
2016
+ { canonical: "couchbase", aliases: [], category: "tool" },
2017
+ { canonical: "neo4j", aliases: [], category: "tool" },
2018
+ { canonical: "firebase", aliases: [], category: "tool" },
2019
+ { canonical: "supabase", aliases: [], category: "tool" },
1108
2020
  { canonical: "graphql", aliases: ["gql"], category: "tool" },
1109
2021
  { canonical: "aws", aliases: ["amazon web services"], category: "tool" },
2022
+ { canonical: "amazon ec2", aliases: ["ec2"], category: "tool" },
2023
+ { canonical: "amazon s3", aliases: ["s3"], category: "tool" },
2024
+ { canonical: "amazon rds", aliases: ["rds"], category: "tool" },
2025
+ { canonical: "aws lambda", aliases: ["lambda"], category: "tool" },
2026
+ { canonical: "amazon cloudfront", aliases: ["cloudfront"], category: "tool" },
2027
+ { canonical: "amazon sqs", aliases: ["sqs"], category: "tool" },
2028
+ { canonical: "amazon sns", aliases: ["sns"], category: "tool" },
1110
2029
  { canonical: "azure", aliases: ["microsoft azure"], category: "tool" },
1111
- { canonical: "gcp", aliases: ["google cloud", "google cloud platform"], category: "tool" },
1112
- { canonical: "docker", aliases: ["containers"], category: "tool" },
2030
+ { canonical: "azure devops", aliases: [], category: "tool" },
2031
+ { canonical: "azure functions", aliases: [], category: "tool" },
2032
+ {
2033
+ canonical: "google cloud platform",
2034
+ aliases: ["gcp", "google cloud"],
2035
+ category: "tool"
2036
+ },
2037
+ { canonical: "google bigquery", aliases: ["bigquery"], category: "tool" },
2038
+ { canonical: "google kubernetes engine", aliases: ["gke"], category: "tool" },
2039
+ {
2040
+ canonical: "docker",
2041
+ aliases: ["containers", "containerization"],
2042
+ category: "tool"
2043
+ },
1113
2044
  { canonical: "kubernetes", aliases: ["k8s"], category: "tool" },
2045
+ { canonical: "helm", aliases: [], category: "tool" },
1114
2046
  { canonical: "terraform", aliases: [], category: "tool" },
2047
+ { canonical: "pulumi", aliases: [], category: "tool" },
1115
2048
  { canonical: "ansible", aliases: [], category: "tool" },
2049
+ { canonical: "puppet", aliases: [], category: "tool" },
2050
+ { canonical: "chef", aliases: [], category: "tool" },
1116
2051
  { canonical: "jenkins", aliases: [], category: "tool" },
1117
- { canonical: "git", aliases: ["github", "gitlab"], category: "tool" },
2052
+ { canonical: "circleci", aliases: ["circle ci"], category: "tool" },
2053
+ { canonical: "github actions", aliases: [], category: "tool" },
2054
+ { canonical: "gitlab ci", aliases: ["gitlab ci/cd"], category: "tool" },
2055
+ { canonical: "travis ci", aliases: [], category: "tool" },
2056
+ {
2057
+ canonical: "git",
2058
+ aliases: ["github", "gitlab", "bitbucket"],
2059
+ category: "tool"
2060
+ },
1118
2061
  { canonical: "jira", aliases: [], category: "tool" },
1119
2062
  { canonical: "confluence", aliases: [], category: "tool" },
2063
+ { canonical: "trello", aliases: [], category: "tool" },
2064
+ { canonical: "asana", aliases: [], category: "tool" },
2065
+ { canonical: "monday.com", aliases: ["monday"], category: "tool" },
2066
+ { canonical: "notion", aliases: [], category: "tool" },
2067
+ { canonical: "linear", aliases: [], category: "tool" },
2068
+ { canonical: "slack", aliases: [], category: "tool" },
2069
+ { canonical: "microsoft teams", aliases: ["ms teams"], category: "tool" },
1120
2070
  { canonical: "pytorch", aliases: ["torch"], category: "tool" },
1121
2071
  { canonical: "tensorflow", aliases: ["tf"], category: "tool" },
2072
+ { canonical: "keras", aliases: [], category: "tool" },
1122
2073
  { canonical: "scikit-learn", aliases: ["sklearn"], category: "tool" },
1123
2074
  { canonical: "pandas", aliases: [], category: "tool" },
1124
2075
  { canonical: "numpy", aliases: [], category: "tool" },
2076
+ {
2077
+ canonical: "hugging face",
2078
+ aliases: ["huggingface", "transformers"],
2079
+ category: "tool"
2080
+ },
2081
+ { canonical: "langchain", aliases: [], category: "tool" },
2082
+ { canonical: "openai api", aliases: ["openai", "gpt api"], category: "tool" },
2083
+ { canonical: "jupyter", aliases: ["jupyter notebook"], category: "tool" },
1125
2084
  { canonical: "fastapi", aliases: [], category: "tool" },
1126
2085
  { canonical: "flask", aliases: [], category: "tool" },
1127
2086
  { canonical: "django", aliases: [], category: "tool" },
1128
- { canonical: "kafka", aliases: [], category: "tool" },
2087
+ {
2088
+ canonical: "express.js",
2089
+ aliases: ["express", "expressjs"],
2090
+ category: "tool"
2091
+ },
2092
+ { canonical: "spring boot", aliases: ["spring"], category: "tool" },
2093
+ { canonical: "laravel", aliases: [], category: "tool" },
2094
+ { canonical: "kafka", aliases: ["apache kafka"], category: "tool" },
2095
+ { canonical: "rabbitmq", aliases: [], category: "tool" },
1129
2096
  { canonical: "redis", aliases: [], category: "tool" },
2097
+ { canonical: "memcached", aliases: [], category: "tool" },
1130
2098
  { canonical: "elasticsearch", aliases: ["elastic"], category: "tool" },
1131
- { canonical: "spark", aliases: ["apache spark"], category: "tool" },
2099
+ { canonical: "logstash", aliases: [], category: "tool" },
2100
+ { canonical: "kibana", aliases: [], category: "tool" },
2101
+ { canonical: "grafana", aliases: [], category: "tool" },
2102
+ { canonical: "prometheus", aliases: [], category: "tool" },
2103
+ { canonical: "datadog", aliases: [], category: "tool" },
2104
+ { canonical: "new relic", aliases: [], category: "tool" },
2105
+ { canonical: "splunk", aliases: [], category: "tool" },
2106
+ {
2107
+ canonical: "spark",
2108
+ aliases: ["apache spark", "pyspark"],
2109
+ category: "tool"
2110
+ },
2111
+ { canonical: "hadoop", aliases: ["apache hadoop"], category: "tool" },
2112
+ { canonical: "airflow", aliases: ["apache airflow"], category: "tool" },
2113
+ { canonical: "dbt", aliases: ["data build tool"], category: "tool" },
2114
+ { canonical: "snowflake", aliases: [], category: "tool" },
2115
+ { canonical: "databricks", aliases: [], category: "tool" },
2116
+ { canonical: "looker", aliases: [], category: "tool" },
1132
2117
  { canonical: "tableau", aliases: [], category: "tool" },
1133
2118
  { canonical: "power bi", aliases: ["powerbi"], category: "tool" },
1134
- { canonical: "excel", aliases: ["microsoft excel", "ms excel"], category: "tool" },
2119
+ {
2120
+ canonical: "excel",
2121
+ aliases: ["microsoft excel", "ms excel"],
2122
+ category: "tool"
2123
+ },
2124
+ { canonical: "google sheets", aliases: [], category: "tool" },
2125
+ { canonical: "spss", aliases: [], category: "tool" },
2126
+ { canonical: "sas", aliases: [], category: "tool" },
1135
2127
  { canonical: "salesforce", aliases: [], category: "tool" },
1136
2128
  { canonical: "hubspot", aliases: [], category: "tool" },
2129
+ { canonical: "marketo", aliases: [], category: "tool" },
2130
+ { canonical: "mailchimp", aliases: [], category: "tool" },
2131
+ { canonical: "google analytics", aliases: ["ga4"], category: "tool" },
2132
+ { canonical: "google tag manager", aliases: ["gtm"], category: "tool" },
1137
2133
  { canonical: "sap", aliases: [], category: "tool" },
1138
2134
  { canonical: "quickbooks", aliases: [], category: "tool" },
2135
+ { canonical: "netsuite", aliases: [], category: "tool" },
1139
2136
  { canonical: "workday", aliases: [], category: "tool" },
1140
2137
  { canonical: "zendesk", aliases: [], category: "tool" },
1141
2138
  { canonical: "servicenow", aliases: [], category: "tool" },
2139
+ { canonical: "freshdesk", aliases: [], category: "tool" },
1142
2140
  { canonical: "figma", aliases: [], category: "tool" },
2141
+ { canonical: "sketch", aliases: [], category: "tool" },
2142
+ { canonical: "adobe xd", aliases: ["xd"], category: "tool" },
2143
+ { canonical: "invision", aliases: [], category: "tool" },
1143
2144
  { canonical: "photoshop", aliases: ["adobe photoshop"], category: "tool" },
1144
- { canonical: "illustrator", aliases: ["adobe illustrator"], category: "tool" },
2145
+ {
2146
+ canonical: "illustrator",
2147
+ aliases: ["adobe illustrator"],
2148
+ category: "tool"
2149
+ },
2150
+ {
2151
+ canonical: "after effects",
2152
+ aliases: ["adobe after effects"],
2153
+ category: "tool"
2154
+ },
2155
+ { canonical: "premiere pro", aliases: ["adobe premiere"], category: "tool" },
1145
2156
  { canonical: "autocad", aliases: [], category: "tool" },
2157
+ { canonical: "solidworks", aliases: [], category: "tool" },
2158
+ { canonical: "postman", aliases: [], category: "tool" },
2159
+ { canonical: "selenium", aliases: [], category: "tool" },
2160
+ { canonical: "cypress", aliases: [], category: "tool" },
2161
+ { canonical: "playwright", aliases: [], category: "tool" },
2162
+ { canonical: "jest", aliases: [], category: "tool" },
2163
+ { canonical: "mocha", aliases: [], category: "tool" },
2164
+ { canonical: "junit", aliases: [], category: "tool" },
1146
2165
  // engineering concepts
1147
2166
  { canonical: "accessibility", aliases: ["a11y"], category: "concept" },
1148
2167
  { canonical: "frontend", aliases: ["front-end"], category: "concept" },
1149
2168
  { canonical: "backend", aliases: ["back-end"], category: "concept" },
1150
- { canonical: "security", aliases: ["cybersecurity"], category: "concept" },
1151
- { canonical: "testing", aliases: ["unittest", "pytest"], category: "concept" },
2169
+ {
2170
+ canonical: "security",
2171
+ aliases: ["cybersecurity", "infosec"],
2172
+ category: "concept"
2173
+ },
2174
+ {
2175
+ canonical: "application security",
2176
+ aliases: ["appsec"],
2177
+ category: "concept"
2178
+ },
2179
+ {
2180
+ canonical: "penetration testing",
2181
+ aliases: ["pentesting", "pen testing"],
2182
+ category: "concept"
2183
+ },
2184
+ { canonical: "vulnerability assessment", aliases: [], category: "concept" },
2185
+ {
2186
+ canonical: "identity and access management",
2187
+ aliases: ["iam"],
2188
+ category: "concept"
2189
+ },
2190
+ { canonical: "zero trust", aliases: [], category: "concept" },
2191
+ { canonical: "encryption", aliases: [], category: "concept" },
2192
+ {
2193
+ canonical: "testing",
2194
+ aliases: ["unittest", "pytest"],
2195
+ category: "concept"
2196
+ },
2197
+ { canonical: "unit testing", aliases: [], category: "concept" },
2198
+ { canonical: "integration testing", aliases: [], category: "concept" },
2199
+ {
2200
+ canonical: "end-to-end testing",
2201
+ aliases: ["e2e testing"],
2202
+ category: "concept"
2203
+ },
2204
+ { canonical: "regression testing", aliases: [], category: "concept" },
1152
2205
  { canonical: "microservices", aliases: [], category: "concept" },
1153
- { canonical: "agile", aliases: ["scrum"], category: "concept" },
2206
+ {
2207
+ canonical: "monolithic architecture",
2208
+ aliases: ["monolith"],
2209
+ category: "concept"
2210
+ },
2211
+ {
2212
+ canonical: "event-driven architecture",
2213
+ aliases: ["event driven architecture"],
2214
+ category: "concept"
2215
+ },
2216
+ {
2217
+ canonical: "serverless",
2218
+ aliases: ["serverless architecture"],
2219
+ category: "concept"
2220
+ },
2221
+ { canonical: "agile", aliases: [], category: "concept" },
2222
+ { canonical: "scrum", aliases: [], category: "concept" },
1154
2223
  { canonical: "kanban", aliases: [], category: "concept" },
2224
+ { canonical: "waterfall", aliases: [], category: "concept" },
2225
+ { canonical: "sprint planning", aliases: [], category: "concept" },
1155
2226
  { canonical: "blockchain", aliases: [], category: "concept" },
2227
+ { canonical: "smart contracts", aliases: [], category: "concept" },
1156
2228
  { canonical: "devops", aliases: [], category: "concept" },
1157
- { canonical: "ci/cd", aliases: ["continuous integration", "continuous deployment"], category: "concept" },
1158
- { canonical: "rest api", aliases: ["restful api", "rest apis"], category: "concept" },
2229
+ {
2230
+ canonical: "site reliability engineering",
2231
+ aliases: ["sre"],
2232
+ category: "concept"
2233
+ },
2234
+ {
2235
+ canonical: "infrastructure as code",
2236
+ aliases: ["iac"],
2237
+ category: "concept"
2238
+ },
2239
+ {
2240
+ canonical: "ci/cd",
2241
+ aliases: [
2242
+ "continuous integration",
2243
+ "continuous deployment",
2244
+ "continuous delivery"
2245
+ ],
2246
+ category: "concept"
2247
+ },
2248
+ {
2249
+ canonical: "rest api",
2250
+ aliases: ["restful api", "rest apis"],
2251
+ category: "concept"
2252
+ },
2253
+ { canonical: "api design", aliases: [], category: "concept" },
1159
2254
  { canonical: "design patterns", aliases: [], category: "concept" },
1160
2255
  { canonical: "data structures", aliases: [], category: "concept" },
1161
2256
  { canonical: "algorithms", aliases: [], category: "concept" },
2257
+ {
2258
+ canonical: "object-oriented programming",
2259
+ aliases: ["oop", "object oriented programming"],
2260
+ category: "concept"
2261
+ },
2262
+ { canonical: "functional programming", aliases: [], category: "concept" },
1162
2263
  { canonical: "cloud computing", aliases: [], category: "concept" },
2264
+ { canonical: "cloud migration", aliases: [], category: "concept" },
1163
2265
  { canonical: "system design", aliases: [], category: "concept" },
1164
- { canonical: "tdd", aliases: ["test driven development", "test-driven development"], category: "concept" },
2266
+ { canonical: "high availability", aliases: [], category: "concept" },
2267
+ { canonical: "scalability", aliases: [], category: "concept" },
2268
+ { canonical: "load balancing", aliases: [], category: "concept" },
2269
+ { canonical: "caching", aliases: [], category: "concept" },
2270
+ {
2271
+ canonical: "tdd",
2272
+ aliases: ["test driven development", "test-driven development"],
2273
+ category: "concept"
2274
+ },
2275
+ {
2276
+ canonical: "bdd",
2277
+ aliases: ["behavior driven development", "behavior-driven development"],
2278
+ category: "concept"
2279
+ },
2280
+ { canonical: "code review", aliases: ["code reviews"], category: "concept" },
2281
+ { canonical: "pair programming", aliases: [], category: "concept" },
1165
2282
  { canonical: "ux design", aliases: ["user experience"], category: "concept" },
1166
- { canonical: "ui design", aliases: ["user interface design"], category: "concept" },
2283
+ {
2284
+ canonical: "ui design",
2285
+ aliases: ["user interface design"],
2286
+ category: "concept"
2287
+ },
2288
+ { canonical: "user research", aliases: [], category: "concept" },
2289
+ { canonical: "wireframing", aliases: [], category: "concept" },
2290
+ { canonical: "prototyping", aliases: [], category: "concept" },
2291
+ { canonical: "design systems", aliases: [], category: "concept" },
2292
+ { canonical: "responsive design", aliases: [], category: "concept" },
1167
2293
  { canonical: "project management", aliases: [], category: "concept" },
2294
+ { canonical: "program management", aliases: [], category: "concept" },
1168
2295
  { canonical: "change management", aliases: [], category: "concept" },
1169
2296
  { canonical: "risk management", aliases: [], category: "concept" },
1170
2297
  { canonical: "quality assurance", aliases: ["qa"], category: "concept" },
2298
+ { canonical: "data privacy", aliases: [], category: "concept" },
2299
+ { canonical: "version control", aliases: [], category: "concept" },
2300
+ {
2301
+ canonical: "documentation",
2302
+ aliases: ["technical documentation"],
2303
+ category: "concept"
2304
+ },
2305
+ { canonical: "requirements gathering", aliases: [], category: "concept" },
2306
+ {
2307
+ canonical: "business process improvement",
2308
+ aliases: [],
2309
+ category: "concept"
2310
+ },
2311
+ { canonical: "root cause analysis", aliases: [], category: "concept" },
2312
+ { canonical: "lean", aliases: ["lean methodology"], category: "concept" },
2313
+ { canonical: "six sigma", aliases: [], category: "concept" },
1171
2314
  // product / data domain
1172
- { canonical: "roadmap", aliases: [], category: "domain" },
2315
+ { canonical: "roadmap", aliases: ["product roadmap"], category: "domain" },
1173
2316
  { canonical: "stakeholder management", aliases: [], category: "domain" },
1174
2317
  { canonical: "prioritization", aliases: [], category: "domain" },
1175
2318
  { canonical: "a/b testing", aliases: ["ab testing"], category: "domain" },
1176
2319
  { canonical: "analytics", aliases: [], category: "domain" },
1177
2320
  { canonical: "statistics", aliases: ["stats"], category: "domain" },
1178
2321
  { canonical: "data visualization", aliases: [], category: "domain" },
2322
+ { canonical: "data governance", aliases: [], category: "domain" },
2323
+ { canonical: "product management", aliases: [], category: "domain" },
2324
+ { canonical: "product strategy", aliases: [], category: "domain" },
2325
+ {
2326
+ canonical: "go-to-market strategy",
2327
+ aliases: ["go to market", "gtm strategy"],
2328
+ category: "domain"
2329
+ },
2330
+ {
2331
+ canonical: "user story writing",
2332
+ aliases: ["user stories"],
2333
+ category: "domain"
2334
+ },
2335
+ { canonical: "competitive analysis", aliases: [], category: "domain" },
2336
+ {
2337
+ canonical: "okrs",
2338
+ aliases: ["objectives and key results"],
2339
+ category: "domain"
2340
+ },
2341
+ {
2342
+ canonical: "kpis",
2343
+ aliases: ["key performance indicators"],
2344
+ category: "domain"
2345
+ },
1179
2346
  // finance / accounting domain
1180
2347
  { canonical: "financial analysis", aliases: [], category: "domain" },
2348
+ { canonical: "financial modeling", aliases: [], category: "domain" },
1181
2349
  { canonical: "budgeting", aliases: [], category: "domain" },
1182
2350
  { canonical: "forecasting", aliases: [], category: "domain" },
1183
2351
  { canonical: "bookkeeping", aliases: [], category: "domain" },
@@ -1187,78 +2355,260 @@ var defaultKeywordRegistry = [
1187
2355
  { canonical: "auditing", aliases: ["audit"], category: "domain" },
1188
2356
  { canonical: "tax preparation", aliases: [], category: "domain" },
1189
2357
  { canonical: "gaap", aliases: [], category: "domain" },
2358
+ { canonical: "ifrs", aliases: [], category: "domain" },
2359
+ {
2360
+ canonical: "reconciliation",
2361
+ aliases: ["account reconciliation"],
2362
+ category: "domain"
2363
+ },
2364
+ { canonical: "general ledger", aliases: [], category: "domain" },
2365
+ { canonical: "cost accounting", aliases: [], category: "domain" },
2366
+ {
2367
+ canonical: "mergers and acquisitions",
2368
+ aliases: ["m&a"],
2369
+ category: "domain"
2370
+ },
2371
+ { canonical: "investment banking", aliases: [], category: "domain" },
2372
+ { canonical: "portfolio management", aliases: [], category: "domain" },
2373
+ { canonical: "equity research", aliases: [], category: "domain" },
2374
+ {
2375
+ canonical: "treasury management",
2376
+ aliases: ["treasury"],
2377
+ category: "domain"
2378
+ },
2379
+ { canonical: "fintech", aliases: [], category: "domain" },
2380
+ {
2381
+ canonical: "payments processing",
2382
+ aliases: ["payment processing"],
2383
+ category: "domain"
2384
+ },
1190
2385
  // sales / account management domain
1191
2386
  { canonical: "lead generation", aliases: [], category: "domain" },
1192
2387
  { canonical: "account management", aliases: [], category: "domain" },
1193
- { canonical: "crm", aliases: ["customer relationship management"], category: "domain" },
1194
- { canonical: "sales pipeline", aliases: [], category: "domain" },
2388
+ {
2389
+ canonical: "crm",
2390
+ aliases: ["customer relationship management"],
2391
+ category: "domain"
2392
+ },
2393
+ {
2394
+ canonical: "sales pipeline",
2395
+ aliases: ["pipeline management"],
2396
+ category: "domain"
2397
+ },
1195
2398
  { canonical: "cold calling", aliases: [], category: "domain" },
1196
2399
  { canonical: "upselling", aliases: ["cross-selling"], category: "domain" },
1197
2400
  { canonical: "customer retention", aliases: [], category: "domain" },
2401
+ {
2402
+ canonical: "business development",
2403
+ aliases: ["bizdev"],
2404
+ category: "domain"
2405
+ },
2406
+ { canonical: "quota attainment", aliases: [], category: "domain" },
2407
+ { canonical: "b2b sales", aliases: [], category: "domain" },
2408
+ { canonical: "b2c sales", aliases: [], category: "domain" },
2409
+ { canonical: "saas", aliases: ["software as a service"], category: "domain" },
2410
+ { canonical: "e-commerce", aliases: ["ecommerce"], category: "domain" },
1198
2411
  // human resources domain
1199
- { canonical: "recruiting", aliases: ["talent acquisition"], category: "domain" },
2412
+ {
2413
+ canonical: "recruiting",
2414
+ aliases: ["talent acquisition"],
2415
+ category: "domain"
2416
+ },
1200
2417
  { canonical: "onboarding", aliases: [], category: "domain" },
1201
2418
  { canonical: "employee relations", aliases: [], category: "domain" },
1202
2419
  { canonical: "benefits administration", aliases: [], category: "domain" },
1203
2420
  { canonical: "performance management", aliases: [], category: "domain" },
2421
+ {
2422
+ canonical: "compensation and benefits",
2423
+ aliases: ["comp and benefits"],
2424
+ category: "domain"
2425
+ },
2426
+ {
2427
+ canonical: "diversity and inclusion",
2428
+ aliases: ["dei"],
2429
+ category: "domain"
2430
+ },
2431
+ { canonical: "employee engagement", aliases: [], category: "domain" },
2432
+ {
2433
+ canonical: "hris",
2434
+ aliases: ["human resources information system"],
2435
+ category: "domain"
2436
+ },
1204
2437
  // healthcare domain
1205
2438
  { canonical: "patient care", aliases: [], category: "domain" },
1206
2439
  { canonical: "clinical documentation", aliases: [], category: "domain" },
1207
2440
  { canonical: "hipaa", aliases: [], category: "domain" },
1208
- { canonical: "electronic health records", aliases: ["ehr", "emr"], category: "domain" },
2441
+ {
2442
+ canonical: "electronic health records",
2443
+ aliases: ["ehr", "emr"],
2444
+ category: "domain"
2445
+ },
1209
2446
  { canonical: "medical billing", aliases: [], category: "domain" },
2447
+ {
2448
+ canonical: "medical coding",
2449
+ aliases: ["icd-10", "cpt coding"],
2450
+ category: "domain"
2451
+ },
2452
+ { canonical: "telehealth", aliases: [], category: "domain" },
2453
+ { canonical: "clinical trials", aliases: [], category: "domain" },
2454
+ { canonical: "regulatory affairs", aliases: [], category: "domain" },
1210
2455
  // legal domain
1211
2456
  { canonical: "contract review", aliases: [], category: "domain" },
1212
2457
  { canonical: "legal research", aliases: [], category: "domain" },
1213
2458
  { canonical: "litigation", aliases: [], category: "domain" },
1214
- { canonical: "regulatory compliance", aliases: ["compliance"], category: "domain" },
2459
+ {
2460
+ canonical: "regulatory compliance",
2461
+ aliases: ["compliance"],
2462
+ category: "domain"
2463
+ },
1215
2464
  { canonical: "due diligence", aliases: [], category: "domain" },
2465
+ {
2466
+ canonical: "intellectual property",
2467
+ aliases: ["ip law"],
2468
+ category: "domain"
2469
+ },
2470
+ {
2471
+ canonical: "gdpr",
2472
+ aliases: ["general data protection regulation"],
2473
+ category: "domain"
2474
+ },
2475
+ { canonical: "soc 2", aliases: ["soc2"], category: "domain" },
2476
+ { canonical: "iso 27001", aliases: [], category: "domain" },
2477
+ { canonical: "pci dss", aliases: ["pci compliance"], category: "domain" },
1216
2478
  // education domain
1217
2479
  { canonical: "curriculum development", aliases: [], category: "domain" },
1218
2480
  { canonical: "lesson planning", aliases: [], category: "domain" },
1219
2481
  { canonical: "classroom management", aliases: [], category: "domain" },
1220
2482
  { canonical: "instructional design", aliases: [], category: "domain" },
2483
+ { canonical: "student assessment", aliases: [], category: "domain" },
2484
+ { canonical: "e-learning", aliases: ["elearning"], category: "domain" },
1221
2485
  // operations / supply chain domain
1222
- { canonical: "supply chain management", aliases: ["supply chain"], category: "domain" },
2486
+ {
2487
+ canonical: "supply chain management",
2488
+ aliases: ["supply chain"],
2489
+ category: "domain"
2490
+ },
1223
2491
  { canonical: "inventory management", aliases: [], category: "domain" },
1224
2492
  { canonical: "procurement", aliases: [], category: "domain" },
1225
2493
  { canonical: "vendor management", aliases: [], category: "domain" },
1226
2494
  { canonical: "logistics", aliases: [], category: "domain" },
2495
+ { canonical: "warehouse management", aliases: [], category: "domain" },
2496
+ { canonical: "demand planning", aliases: [], category: "domain" },
2497
+ { canonical: "manufacturing", aliases: [], category: "domain" },
2498
+ { canonical: "quality control", aliases: [], category: "domain" },
2499
+ { canonical: "lean manufacturing", aliases: [], category: "domain" },
1227
2500
  // customer service domain
1228
- { canonical: "customer support", aliases: ["customer service"], category: "domain" },
2501
+ {
2502
+ canonical: "customer support",
2503
+ aliases: ["customer service"],
2504
+ category: "domain"
2505
+ },
1229
2506
  { canonical: "technical support", aliases: [], category: "domain" },
1230
2507
  { canonical: "conflict resolution", aliases: [], category: "domain" },
2508
+ { canonical: "customer success", aliases: [], category: "domain" },
2509
+ { canonical: "help desk", aliases: ["helpdesk"], category: "domain" },
1231
2510
  // soft skills
1232
2511
  { canonical: "communication", aliases: [], category: "soft" },
2512
+ { canonical: "written communication", aliases: [], category: "soft" },
2513
+ { canonical: "verbal communication", aliases: [], category: "soft" },
1233
2514
  { canonical: "leadership", aliases: [], category: "soft" },
2515
+ { canonical: "team leadership", aliases: [], category: "soft" },
1234
2516
  { canonical: "teamwork", aliases: ["collaboration"], category: "soft" },
1235
- { canonical: "problem solving", aliases: ["problem-solving"], category: "soft" },
2517
+ {
2518
+ canonical: "cross-functional collaboration",
2519
+ aliases: ["cross functional collaboration"],
2520
+ category: "soft"
2521
+ },
2522
+ {
2523
+ canonical: "problem solving",
2524
+ aliases: ["problem-solving"],
2525
+ category: "soft"
2526
+ },
1236
2527
  { canonical: "adaptability", aliases: ["flexibility"], category: "soft" },
1237
2528
  { canonical: "time management", aliases: [], category: "soft" },
1238
2529
  { canonical: "critical thinking", aliases: [], category: "soft" },
1239
2530
  { canonical: "creativity", aliases: [], category: "soft" },
1240
2531
  { canonical: "attention to detail", aliases: [], category: "soft" },
1241
- { canonical: "decision making", aliases: ["decision-making"], category: "soft" },
2532
+ {
2533
+ canonical: "decision making",
2534
+ aliases: ["decision-making"],
2535
+ category: "soft"
2536
+ },
1242
2537
  { canonical: "emotional intelligence", aliases: [], category: "soft" },
1243
2538
  { canonical: "negotiation", aliases: [], category: "soft" },
1244
- { canonical: "organization", aliases: ["organizational skills"], category: "soft" },
1245
- { canonical: "public speaking", aliases: ["presentation skills"], category: "soft" },
2539
+ {
2540
+ canonical: "organization",
2541
+ aliases: ["organizational skills"],
2542
+ category: "soft"
2543
+ },
2544
+ {
2545
+ canonical: "public speaking",
2546
+ aliases: ["presentation skills"],
2547
+ category: "soft"
2548
+ },
1246
2549
  { canonical: "mentoring", aliases: ["coaching"], category: "soft" },
1247
2550
  { canonical: "interpersonal skills", aliases: [], category: "soft" },
1248
2551
  { canonical: "work ethic", aliases: [], category: "soft" },
2552
+ { canonical: "stakeholder communication", aliases: [], category: "soft" },
2553
+ {
2554
+ canonical: "self-motivation",
2555
+ aliases: ["self motivation"],
2556
+ category: "soft"
2557
+ },
2558
+ { canonical: "resilience", aliases: [], category: "soft" },
2559
+ { canonical: "empathy", aliases: [], category: "soft" },
2560
+ { canonical: "active listening", aliases: [], category: "soft" },
2561
+ { canonical: "delegation", aliases: [], category: "soft" },
2562
+ { canonical: "strategic thinking", aliases: [], category: "soft" },
2563
+ { canonical: "multitasking", aliases: [], category: "soft" },
1249
2564
  // marketing
1250
- { canonical: "seo", aliases: ["search engine optimization"], category: "marketing" },
2565
+ {
2566
+ canonical: "seo",
2567
+ aliases: ["search engine optimization"],
2568
+ category: "marketing"
2569
+ },
2570
+ {
2571
+ canonical: "sem",
2572
+ aliases: ["search engine marketing"],
2573
+ category: "marketing"
2574
+ },
1251
2575
  { canonical: "branding", aliases: ["brand strategy"], category: "marketing" },
1252
2576
  { canonical: "campaign management", aliases: [], category: "marketing" },
1253
2577
  { canonical: "content marketing", aliases: [], category: "marketing" },
1254
- { canonical: "social media marketing", aliases: ["social media"], category: "marketing" },
2578
+ { canonical: "content strategy", aliases: [], category: "marketing" },
2579
+ {
2580
+ canonical: "social media marketing",
2581
+ aliases: ["social media"],
2582
+ category: "marketing"
2583
+ },
1255
2584
  { canonical: "email marketing", aliases: [], category: "marketing" },
1256
2585
  { canonical: "digital marketing", aliases: [], category: "marketing" },
2586
+ {
2587
+ canonical: "growth marketing",
2588
+ aliases: ["growth hacking"],
2589
+ category: "marketing"
2590
+ },
1257
2591
  { canonical: "copywriting", aliases: [], category: "marketing" },
1258
2592
  { canonical: "market research", aliases: [], category: "marketing" },
1259
- { canonical: "ppc", aliases: ["pay-per-click", "google ads"], category: "marketing" },
1260
- { canonical: "conversion rate optimization", aliases: ["cro"], category: "marketing" },
1261
- { canonical: "public relations", aliases: ["pr"], category: "marketing" }
2593
+ {
2594
+ canonical: "ppc",
2595
+ aliases: ["pay-per-click", "google ads"],
2596
+ category: "marketing"
2597
+ },
2598
+ {
2599
+ canonical: "conversion rate optimization",
2600
+ aliases: ["cro"],
2601
+ category: "marketing"
2602
+ },
2603
+ { canonical: "public relations", aliases: ["pr"], category: "marketing" },
2604
+ { canonical: "marketing automation", aliases: [], category: "marketing" },
2605
+ { canonical: "affiliate marketing", aliases: [], category: "marketing" },
2606
+ { canonical: "influencer marketing", aliases: [], category: "marketing" },
2607
+ { canonical: "product marketing", aliases: [], category: "marketing" },
2608
+ { canonical: "event marketing", aliases: [], category: "marketing" },
2609
+ { canonical: "brand awareness", aliases: [], category: "marketing" },
2610
+ { canonical: "media planning", aliases: [], category: "marketing" },
2611
+ { canonical: "lead nurturing", aliases: [], category: "marketing" }
1262
2612
  ];
1263
2613
  var defaultSkillAliases = deriveSkillAliases(defaultKeywordRegistry);
1264
2614
  var softwareEngineerProfile = {
@@ -1287,10 +2637,11 @@ var defaultProfiles = [
1287
2637
 
1288
2638
  // src/core/scoring/weights.ts
1289
2639
  var DEFAULT_WEIGHTS = {
1290
- skills: 0.3,
1291
- experience: 0.3,
2640
+ skills: 0.25,
2641
+ experience: 0.2,
1292
2642
  keywords: 0.25,
1293
- education: 0.15
2643
+ parseability: 0.2,
2644
+ education: 0.1
1294
2645
  };
1295
2646
  var DEFAULT_KEYWORD_DENSITY = {
1296
2647
  min: 25e-4,
@@ -1301,16 +2652,20 @@ var DEFAULT_SECTION_PENALTIES = {
1301
2652
  missingSummary: 4,
1302
2653
  missingExperience: 10,
1303
2654
  missingSkills: 8,
1304
- missingEducation: 6
2655
+ missingEducation: 6,
2656
+ // Raised from 0 (warning-only) in v1 — a real ATS treats an unparseable contact email as a
2657
+ // near-knockout (no way to reach the candidate at all), so this now actually moves the score.
2658
+ missingContact: 12
1305
2659
  };
1306
2660
  function normalizeWeights(weights) {
1307
- const total = weights.skills + weights.experience + weights.keywords + weights.education;
2661
+ const total = weights.skills + weights.experience + weights.keywords + weights.parseability + weights.education;
1308
2662
  if (total === 0) {
1309
- const equal = 1 / 4;
2663
+ const equal = 1 / 5;
1310
2664
  return {
1311
2665
  skills: equal,
1312
2666
  experience: equal,
1313
2667
  keywords: equal,
2668
+ parseability: equal,
1314
2669
  education: equal,
1315
2670
  normalizedTotal: 1
1316
2671
  };
@@ -1319,6 +2674,7 @@ function normalizeWeights(weights) {
1319
2674
  skills: weights.skills / total,
1320
2675
  experience: weights.experience / total,
1321
2676
  keywords: weights.keywords / total,
2677
+ parseability: weights.parseability / total,
1322
2678
  education: weights.education / total,
1323
2679
  normalizedTotal: 1
1324
2680
  };
@@ -1328,12 +2684,19 @@ function resolveConfig(config = {}) {
1328
2684
  skills: config.weights?.skills ?? DEFAULT_WEIGHTS.skills,
1329
2685
  experience: config.weights?.experience ?? DEFAULT_WEIGHTS.experience,
1330
2686
  keywords: config.weights?.keywords ?? DEFAULT_WEIGHTS.keywords,
2687
+ parseability: config.weights?.parseability ?? DEFAULT_WEIGHTS.parseability,
1331
2688
  education: config.weights?.education ?? DEFAULT_WEIGHTS.education
1332
2689
  };
1333
- const keywordRegistry = mergeKeywordRegistries(defaultKeywordRegistry, config.keywordRegistry ?? []);
2690
+ const keywordRegistry = mergeKeywordRegistries(
2691
+ defaultKeywordRegistry,
2692
+ config.keywordRegistry ?? []
2693
+ );
1334
2694
  const resolved = {
1335
2695
  weights: normalizeWeights(weights),
1336
- skillAliases: { ...deriveSkillAliases(keywordRegistry), ...config.skillAliases ?? {} },
2696
+ skillAliases: {
2697
+ ...deriveSkillAliases(keywordRegistry),
2698
+ ...config.skillAliases ?? {}
2699
+ },
1337
2700
  keywordRegistry,
1338
2701
  categoryIndex: buildCategoryIndex(keywordRegistry),
1339
2702
  profile: config.profile ?? softwareEngineerProfile,
@@ -1344,6 +2707,13 @@ function resolveConfig(config = {}) {
1344
2707
  ...config.sectionPenalties ?? {}
1345
2708
  },
1346
2709
  allowPartialMatches: config.allowPartialMatches ?? true,
2710
+ // Fuzzy/stem matching is on by default in v2 (typos, word-form variants like "developing"
2711
+ // vs "develop", "ReactJS" vs "react") — callers who want v1's exact-match-only behavior can
2712
+ // override via config.matching = { fuzzy: false }.
2713
+ matching: {
2714
+ fuzzy: config.matching?.fuzzy ?? true,
2715
+ threshold: config.matching?.threshold
2716
+ },
1347
2717
  referenceDate: config.referenceDate ? new Date(config.referenceDate) : void 0
1348
2718
  };
1349
2719
  return resolved;
@@ -1356,13 +2726,17 @@ function formatList(values, max = 6) {
1356
2726
  return trimmed.join(", ") + (uniqueValues.length > max ? "..." : "");
1357
2727
  }
1358
2728
  function buildAliasReplacementSuggestions(resume, job, config) {
1359
- const jobKeywordSet = new Set(job.keywords.map((k) => normalizeSkill(k, config.skillAliases)));
2729
+ const jobKeywordSet = new Set(
2730
+ job.keywords.map((k) => normalizeSkill(k, config.skillAliases))
2731
+ );
1360
2732
  const replacements = [];
1361
2733
  for (const token of unique(tokenize(resume.normalizedText))) {
1362
2734
  const canonical = normalizeSkill(token, config.skillAliases);
1363
2735
  const jdSurface = job.keywordSurfaceForms[canonical];
1364
2736
  if (jdSurface && jobKeywordSet.has(canonical) && jdSurface.toLowerCase() !== token.toLowerCase()) {
1365
- replacements.push(`Replace "${token}" with "${jdSurface}" to match the job description's wording.`);
2737
+ replacements.push(
2738
+ `Replace "${token}" with "${jdSurface}" to match the job description's wording.`
2739
+ );
1366
2740
  }
1367
2741
  }
1368
2742
  return unique(replacements).slice(0, 5);
@@ -1370,7 +2744,10 @@ function buildAliasReplacementSuggestions(resume, job, config) {
1370
2744
  var SuggestionEngine = class {
1371
2745
  generate(input) {
1372
2746
  const suggestions = [];
1373
- const warnings = [...input.ruleWarnings, ...input.resume.warnings];
2747
+ const warnings = [
2748
+ ...input.ruleWarnings,
2749
+ ...input.resume.warnings
2750
+ ];
1374
2751
  if (input.score.missingSkills.length > 0) {
1375
2752
  suggestions.push(
1376
2753
  `Highlight these required skills: ${formatList(input.score.missingSkills)}`
@@ -1381,7 +2758,9 @@ var SuggestionEngine = class {
1381
2758
  `Incorporate job-specific keywords: ${formatList(input.score.missingKeywords)}`
1382
2759
  );
1383
2760
  }
1384
- suggestions.push(...buildAliasReplacementSuggestions(input.resume, input.job, input.config));
2761
+ suggestions.push(
2762
+ ...buildAliasReplacementSuggestions(input.resume, input.job, input.config)
2763
+ );
1385
2764
  if (input.score.overusedKeywords.length > 0) {
1386
2765
  suggestions.push(
1387
2766
  `Avoid keyword stuffing for: ${formatList(input.score.overusedKeywords)}`
@@ -1392,7 +2771,7 @@ var SuggestionEngine = class {
1392
2771
  `Clarify at least ${input.job.minExperienceYears ?? input.score.missingExperienceYears} years of relevant experience with quantified achievements.`
1393
2772
  );
1394
2773
  }
1395
- if (input.job.educationRequirements.length > 0 && input.score.educationScore === 0) {
2774
+ if (input.job.educationRequirements.length > 0 && input.score.educationScore < 60) {
1396
2775
  suggestions.push(
1397
2776
  `State your education credentials matching: ${formatList(input.job.educationRequirements)}`
1398
2777
  );
@@ -1411,7 +2790,9 @@ var SuggestionEngine = class {
1411
2790
  const formatted = input.score.missingLanguages.map((l) => l.level ? `${l.name} (${l.level})` : l.name).join(", ");
1412
2791
  suggestions.push(`Mention your proficiency in: ${formatted}`);
1413
2792
  }
1414
- const weakAchievement = input.resume.achievements.find((a) => a.strength === "weak");
2793
+ const weakAchievement = input.resume.achievements.find(
2794
+ (a) => a.strength === "weak"
2795
+ );
1415
2796
  if (weakAchievement) {
1416
2797
  suggestions.push(
1417
2798
  `Strengthen "${weakAchievement.text}" \u2014 add scope/metrics, e.g. "Built and maintained scalable services handling 500k+ requests/day."`
@@ -1422,6 +2803,34 @@ var SuggestionEngine = class {
1422
2803
  "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."
1423
2804
  );
1424
2805
  }
2806
+ if (!input.resume.contact?.email) {
2807
+ suggestions.push(
2808
+ "Add a clearly formatted email address near the top of your resume so ATS and recruiters can contact you."
2809
+ );
2810
+ }
2811
+ for (const gap of input.score.skillExperienceGaps) {
2812
+ suggestions.push(
2813
+ `The role asks for ${gap.requiredYears}+ years of ${gap.skill}; make that duration explicit in your experience section.`
2814
+ );
2815
+ }
2816
+ if (input.score.breakdown.parseability < 80) {
2817
+ for (const deduction of input.score.parseabilityReport.deductions) {
2818
+ suggestions.push(`Improve resume parseability: ${deduction.reason}.`);
2819
+ }
2820
+ }
2821
+ const GAP_THRESHOLD_MONTHS = 6;
2822
+ for (const gap of input.score.employmentGaps) {
2823
+ if (gap.months >= GAP_THRESHOLD_MONTHS) {
2824
+ suggestions.push(
2825
+ `Address the ${gap.months}-month employment gap after "${gap.afterRole}" \u2014 a brief explanation (upskilling, caregiving, freelance work, etc.) reassures recruiters.`
2826
+ );
2827
+ }
2828
+ }
2829
+ if (!input.score.seniorityMatch.met) {
2830
+ suggestions.push(
2831
+ `This role expects ${input.score.seniorityMatch.required} seniority, but your resume reads as ${input.score.seniorityMatch.resume}; emphasize scope/ownership that reflects the level the role expects.`
2832
+ );
2833
+ }
1425
2834
  return { suggestions, warnings };
1426
2835
  }
1427
2836
  };
@@ -1469,7 +2878,10 @@ var LLMBudgetManager = class {
1469
2878
  callsUsed: this.callCount,
1470
2879
  callsRemaining: Math.max(0, this.limits.maxCalls - this.callCount),
1471
2880
  tokensUsed: this.totalTokensUsed,
1472
- tokensRemaining: Math.max(0, this.limits.maxTotalTokens - this.totalTokensUsed),
2881
+ tokensRemaining: Math.max(
2882
+ 0,
2883
+ this.limits.maxTotalTokens - this.totalTokensUsed
2884
+ ),
1473
2885
  totalCalls: this.limits.maxCalls,
1474
2886
  totalTokens: this.limits.maxTotalTokens
1475
2887
  };
@@ -1491,7 +2903,8 @@ var LLMBudgetManager = class {
1491
2903
 
1492
2904
  // src/llm/validation.ts
1493
2905
  function validateJsonSchema(data, schema) {
1494
- if (schema.type !== "object" || typeof data !== "object" || data === null) return false;
2906
+ if (schema.type !== "object" || typeof data !== "object" || data === null)
2907
+ return false;
1495
2908
  const obj = data;
1496
2909
  if (schema.required) {
1497
2910
  for (const r of schema.required) {
@@ -1521,9 +2934,12 @@ function validateJsonSchema(data, schema) {
1521
2934
  if (items && items.type && Array.isArray(value)) {
1522
2935
  const itemType = items.type;
1523
2936
  for (const item of value) {
1524
- if (itemType === "string" && typeof item !== "string") return false;
1525
- if (itemType === "number" && typeof item !== "number") return false;
1526
- if (itemType === "object" && (typeof item !== "object" || item === null)) return false;
2937
+ if (itemType === "string" && typeof item !== "string")
2938
+ return false;
2939
+ if (itemType === "number" && typeof item !== "number")
2940
+ return false;
2941
+ if (itemType === "object" && (typeof item !== "object" || item === null))
2942
+ return false;
1527
2943
  }
1528
2944
  }
1529
2945
  break;
@@ -1550,24 +2966,36 @@ var LLMManager = class {
1550
2966
  */
1551
2967
  async callLLM(systemPrompt, userPrompt, schema, options = {}) {
1552
2968
  const onUnhandled = (reason) => {
1553
- this.warnings.push(`Unhandled rejection during LLM call: ${String(reason)}`);
2969
+ this.warnings.push(
2970
+ `Unhandled rejection during LLM call: ${String(reason)}`
2971
+ );
1554
2972
  };
1555
2973
  process.on("unhandledRejection", onUnhandled);
1556
2974
  let clientPromise = void 0;
1557
2975
  try {
1558
- const estimatedTokens = this.estimateTokens(systemPrompt, userPrompt, options.requestedTokens);
2976
+ const estimatedTokens = this.estimateTokens(
2977
+ systemPrompt,
2978
+ userPrompt,
2979
+ options.requestedTokens
2980
+ );
1559
2981
  try {
1560
2982
  this.budgetManager.assertCanCall(estimatedTokens);
1561
2983
  } catch (e) {
1562
2984
  const msg = `LLM budget exhausted: ${e.message}`;
1563
2985
  this.warnings.push(msg);
1564
- globalThis.setTimeout(() => process.removeListener("unhandledRejection", onUnhandled), 100);
2986
+ globalThis.setTimeout(
2987
+ () => process.removeListener("unhandledRejection", onUnhandled),
2988
+ 100
2989
+ );
1565
2990
  return { success: false, fallback: true, error: msg };
1566
2991
  }
1567
2992
  if (!this.isValidJsonSchema(schema)) {
1568
2993
  const msg = "Invalid JSON schema provided";
1569
2994
  this.warnings.push(msg);
1570
- globalThis.setTimeout(() => process.removeListener("unhandledRejection", onUnhandled), 100);
2995
+ globalThis.setTimeout(
2996
+ () => process.removeListener("unhandledRejection", onUnhandled),
2997
+ 100
2998
+ );
1571
2999
  return { success: false, fallback: true, error: msg };
1572
3000
  }
1573
3001
  const strictUserPrompt = `${userPrompt}
@@ -1586,22 +3014,36 @@ No explanations. No markdown. No additional text.`;
1586
3014
  void clientPromise.catch(() => {
1587
3015
  });
1588
3016
  const timeoutPromise = new Promise((_, reject) => {
1589
- const id = globalThis.setTimeout(() => reject(new Error(`LLM call timeout after ${this.timeoutMs}ms`)), this.timeoutMs);
1590
- void clientPromise.finally(() => globalThis.clearTimeout(id));
3017
+ const id = globalThis.setTimeout(
3018
+ () => reject(new Error(`LLM call timeout after ${this.timeoutMs}ms`)),
3019
+ this.timeoutMs
3020
+ );
3021
+ void clientPromise.finally(() => globalThis.clearTimeout(id)).catch(() => {
3022
+ });
1591
3023
  });
1592
3024
  void timeoutPromise.catch(() => {
1593
3025
  });
1594
- const response = await Promise.race([clientPromise, timeoutPromise]);
3026
+ const response = await Promise.race([
3027
+ clientPromise,
3028
+ timeoutPromise
3029
+ ]);
1595
3030
  if (!response || !response.content) {
1596
3031
  const graceMs = Math.min(Math.max(this.timeoutMs * 2, 100), 500);
1597
3032
  try {
1598
- await Promise.race([clientPromise.catch(() => {
1599
- }), new Promise((r) => setTimeout(r, graceMs))]);
1600
- } catch (e) {
3033
+ await Promise.race([
3034
+ clientPromise.catch(() => {
3035
+ }),
3036
+ new Promise((r) => setTimeout(r, graceMs))
3037
+ ]);
3038
+ } catch {
1601
3039
  } finally {
1602
3040
  process.removeListener("unhandledRejection", onUnhandled);
1603
3041
  }
1604
- return { success: false, fallback: true, error: "Empty response from LLM" };
3042
+ return {
3043
+ success: false,
3044
+ fallback: true,
3045
+ error: "Empty response from LLM"
3046
+ };
1605
3047
  }
1606
3048
  let parsedContent;
1607
3049
  try {
@@ -1615,9 +3057,12 @@ No explanations. No markdown. No additional text.`;
1615
3057
  this.warnings.push(msg);
1616
3058
  const graceMs = Math.min(Math.max(this.timeoutMs * 2, 100), 500);
1617
3059
  try {
1618
- await Promise.race([clientPromise.catch(() => {
1619
- }), new Promise((r) => setTimeout(r, graceMs))]);
1620
- } catch (e2) {
3060
+ await Promise.race([
3061
+ clientPromise.catch(() => {
3062
+ }),
3063
+ new Promise((r) => setTimeout(r, graceMs))
3064
+ ]);
3065
+ } catch {
1621
3066
  } finally {
1622
3067
  process.removeListener("unhandledRejection", onUnhandled);
1623
3068
  }
@@ -1628,9 +3073,12 @@ No explanations. No markdown. No additional text.`;
1628
3073
  this.warnings.push(msg);
1629
3074
  const graceMs = Math.min(Math.max(this.timeoutMs * 2, 100), 500);
1630
3075
  try {
1631
- await Promise.race([clientPromise.catch(() => {
1632
- }), new Promise((r) => setTimeout(r, graceMs))]);
1633
- } catch (e) {
3076
+ await Promise.race([
3077
+ clientPromise.catch(() => {
3078
+ }),
3079
+ new Promise((r) => setTimeout(r, graceMs))
3080
+ ]);
3081
+ } catch {
1634
3082
  } finally {
1635
3083
  process.removeListener("unhandledRejection", onUnhandled);
1636
3084
  }
@@ -1650,9 +3098,12 @@ No explanations. No markdown. No additional text.`;
1650
3098
  this.warnings.push(msg);
1651
3099
  const graceMs = Math.min(Math.max(this.timeoutMs * 2, 100), 500);
1652
3100
  try {
1653
- await Promise.race([clientPromise.catch(() => {
1654
- }), new Promise((r) => setTimeout(r, graceMs))]);
1655
- } catch (e2) {
3101
+ await Promise.race([
3102
+ clientPromise.catch(() => {
3103
+ }),
3104
+ new Promise((r) => setTimeout(r, graceMs))
3105
+ ]);
3106
+ } catch {
1656
3107
  } finally {
1657
3108
  process.removeListener("unhandledRejection", onUnhandled);
1658
3109
  }
@@ -1702,7 +3153,7 @@ No explanations. No markdown. No additional text.`;
1702
3153
  validateAgainstSchema(data, schema) {
1703
3154
  try {
1704
3155
  return validateJsonSchema(data, schema);
1705
- } catch (e) {
3156
+ } catch {
1706
3157
  if (typeof data !== "object" || data === null) {
1707
3158
  return false;
1708
3159
  }
@@ -1762,7 +3213,15 @@ var sectionClassificationSchema = {
1762
3213
  },
1763
3214
  classification: {
1764
3215
  type: "string",
1765
- enum: ["summary", "experience", "skills", "education", "projects", "certifications", "other"],
3216
+ enum: [
3217
+ "summary",
3218
+ "experience",
3219
+ "skills",
3220
+ "education",
3221
+ "projects",
3222
+ "certifications",
3223
+ "other"
3224
+ ],
1766
3225
  description: "Classified section type"
1767
3226
  },
1768
3227
  confidence: {
@@ -2065,7 +3524,10 @@ function analyzeResume(input) {
2065
3524
  let suggestions = suggestionResult.suggestions;
2066
3525
  const llmWarnings = [];
2067
3526
  if (input.llm && suggestionResult.suggestions.length > 0) {
2068
- const llmResult = enhanceSuggestionsWithLLM(input.llm, suggestionResult.suggestions);
3527
+ const llmResult = enhanceSuggestionsWithLLM(
3528
+ input.llm,
3529
+ suggestionResult.suggestions
3530
+ );
2069
3531
  if (llmResult.success) {
2070
3532
  suggestions = llmResult.enhancedSuggestions || suggestions;
2071
3533
  }
@@ -2085,21 +3547,25 @@ function analyzeResume(input) {
2085
3547
  achievementStrength: scoring.achievementStrength,
2086
3548
  matchedLanguages: scoring.matchedLanguages,
2087
3549
  missingLanguages: scoring.missingLanguages,
3550
+ skillExperienceGaps: scoring.skillExperienceGaps,
2088
3551
  experienceGap: scoring.experienceGap,
2089
3552
  detectedSections: parsedResume.detectedSections,
2090
3553
  parsedExperienceYears: parsedResume.totalExperienceYears,
2091
3554
  experienceEntries: parsedResume.experience,
3555
+ parseabilityReport: scoring.parseabilityReport,
3556
+ employmentGaps: scoring.employmentGaps,
3557
+ seniorityMatch: scoring.seniorityMatch,
3558
+ perSkillExperience: scoring.perSkillExperience,
2092
3559
  suggestions,
2093
3560
  warnings: [...suggestionResult.warnings, ...llmWarnings]
2094
3561
  };
2095
3562
  }
2096
- function enhanceSuggestionsWithLLM(config, suggestions) {
3563
+ function enhanceSuggestionsWithLLM(config, _suggestions) {
2097
3564
  if (!config.enable?.suggestions) {
2098
3565
  return { success: false, warnings: [] };
2099
3566
  }
2100
3567
  const warnings = [];
2101
3568
  try {
2102
- const llmManager = new LLMManager(config);
2103
3569
  warnings.push(
2104
3570
  "LLM suggestion enhancement skipped - use async analyzeResumeAsync for LLM features"
2105
3571
  );
@@ -2156,10 +3622,15 @@ async function analyzeResumeAsync(input) {
2156
3622
  achievementStrength: scoring.achievementStrength,
2157
3623
  matchedLanguages: scoring.matchedLanguages,
2158
3624
  missingLanguages: scoring.missingLanguages,
3625
+ skillExperienceGaps: scoring.skillExperienceGaps,
2159
3626
  experienceGap: scoring.experienceGap,
2160
3627
  detectedSections: parsedResume.detectedSections,
2161
3628
  parsedExperienceYears: parsedResume.totalExperienceYears,
2162
3629
  experienceEntries: parsedResume.experience,
3630
+ parseabilityReport: scoring.parseabilityReport,
3631
+ employmentGaps: scoring.employmentGaps,
3632
+ seniorityMatch: scoring.seniorityMatch,
3633
+ perSkillExperience: scoring.perSkillExperience,
2163
3634
  suggestions,
2164
3635
  warnings: [...suggestionResult.warnings, ...llmWarnings]
2165
3636
  };
@@ -2181,13 +3652,19 @@ async function enhanceSuggestionsWithLLMAsync(config, suggestions) {
2181
3652
  if (result.error) {
2182
3653
  warnings.push(`LLM suggestion enhancement failed: ${result.error}`);
2183
3654
  }
2184
- return { success: false, warnings: [...warnings, ...llmManager.getWarnings()] };
3655
+ return {
3656
+ success: false,
3657
+ warnings: [...warnings, ...llmManager.getWarnings()]
3658
+ };
2185
3659
  }
2186
3660
  const enhanced = adaptSuggestionEnhancementResponse(result.data);
2187
3661
  const enhancedSuggestions = enhanced.filter((e) => e.actionable !== false).map((e) => e.enhanced);
2188
3662
  if (enhancedSuggestions.length === 0) {
2189
3663
  warnings.push("LLM returned no actionable enhanced suggestions");
2190
- return { success: false, warnings: [...warnings, ...llmManager.getWarnings()] };
3664
+ return {
3665
+ success: false,
3666
+ warnings: [...warnings, ...llmManager.getWarnings()]
3667
+ };
2191
3668
  }
2192
3669
  return {
2193
3670
  success: true,
@@ -2195,7 +3672,9 @@ async function enhanceSuggestionsWithLLMAsync(config, suggestions) {
2195
3672
  warnings: llmManager.getWarnings()
2196
3673
  };
2197
3674
  } catch (e) {
2198
- warnings.push(`Unexpected error in LLM enhancement: ${e.message}`);
3675
+ warnings.push(
3676
+ `Unexpected error in LLM enhancement: ${e.message}`
3677
+ );
2199
3678
  return { success: false, warnings };
2200
3679
  }
2201
3680
  }