@pranavraut033/ats-checker 1.4.0 → 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
@@ -172,11 +395,14 @@ function splitLines(text) {
172
395
  return text.replace(/\r\n?/g, "\n").split("\n").map((line) => line.trim()).filter(Boolean);
173
396
  }
174
397
  var TECH_TOKEN_RE = /[a-z0-9][a-z0-9.#+\-/]*[a-z0-9#+]/g;
175
- function tokenize(text) {
398
+ function tokenize(text, options) {
176
399
  const normalized = normalizeForComparison(text);
177
- return (normalized.match(TECH_TOKEN_RE) ?? []).filter(
400
+ const tokens = (normalized.match(TECH_TOKEN_RE) ?? []).filter(
178
401
  (t) => /[a-z]/.test(t) && !STOP_WORDS.has(t)
179
402
  );
403
+ {
404
+ return tokens;
405
+ }
180
406
  }
181
407
  function escapeRegExp(input) {
182
408
  return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -216,9 +442,48 @@ function containsTableLikeStructure(text) {
216
442
  }
217
443
  return tableLines >= 2;
218
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
+ }
219
483
 
220
484
  // src/utils/skills.ts
221
485
  var aliasIndexCache = /* @__PURE__ */ new WeakMap();
486
+ var stemIndexCache = /* @__PURE__ */ new WeakMap();
222
487
  function getAliasIndex(aliases) {
223
488
  let index = aliasIndexCache.get(aliases);
224
489
  if (!index) {
@@ -234,12 +499,44 @@ function getAliasIndex(aliases) {
234
499
  }
235
500
  return index;
236
501
  }
237
- 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) {
238
517
  const normalized = skill.trim().toLowerCase();
239
- 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;
240
537
  }
241
- function normalizeSkills(skills, aliases) {
242
- return unique(skills.map((skill) => normalizeSkill(skill, aliases)));
538
+ function normalizeSkills(skills, aliases, options) {
539
+ return unique(skills.map((skill) => normalizeSkill(skill, aliases, options)));
243
540
  }
244
541
  function deriveSkillAliases(registry) {
245
542
  const aliases = {};
@@ -257,141 +554,72 @@ function buildCategoryIndex(registry) {
257
554
  }
258
555
  function mergeKeywordRegistries(base, overrides) {
259
556
  const byCanonical = /* @__PURE__ */ new Map();
260
- for (const entry of base) byCanonical.set(entry.canonical.toLowerCase(), entry);
261
- 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);
262
561
  return [...byCanonical.values()];
263
562
  }
264
563
 
265
- // src/utils/languages.ts
266
- var KNOWN_LANGUAGES = [
267
- "english",
268
- "spanish",
269
- "french",
270
- "german",
271
- "italian",
272
- "portuguese",
273
- "dutch",
274
- "russian",
275
- "mandarin",
276
- "chinese",
277
- "cantonese",
278
- "japanese",
279
- "korean",
280
- "arabic",
281
- "hindi",
282
- "polish",
283
- "turkish",
284
- "vietnamese",
285
- "swedish",
286
- "norwegian",
287
- "danish",
288
- "finnish",
289
- "greek",
290
- "hebrew",
291
- "thai",
292
- "indonesian",
293
- "ukrainian",
294
- "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
+ }
295
573
  ];
296
- var LANGUAGE_ALIASES = {
297
- mandarin: "chinese",
298
- cantonese: "chinese"
299
- };
300
- var LEVEL_RANK = {
301
- a1: 1,
302
- a2: 2,
303
- b1: 3,
304
- b2: 4,
305
- c1: 5,
306
- c2: 6,
307
- basic: 1,
308
- elementary: 1,
309
- limited: 2,
310
- conversational: 3,
311
- intermediate: 3,
312
- professional: 4,
313
- "upper intermediate": 4,
314
- advanced: 4,
315
- fluent: 5,
316
- native: 6,
317
- "native speaker": 6,
318
- bilingual: 6,
319
- // German
320
- grundkenntnisse: 1,
321
- gering: 2,
322
- gut: 3,
323
- fortgeschritten: 4,
324
- flie\u00DFend: 5,
325
- muttersprache: 6,
326
- muttersprachler: 6,
327
- // French
328
- "d\xE9butant": 1,
329
- "\xE9l\xE9mentaire": 1,
330
- "limit\xE9": 2,
331
- "interm\xE9diaire": 3,
332
- "avanc\xE9": 4,
333
- courant: 5,
334
- natif: 6,
335
- "langue maternelle": 6,
336
- bilingue: 6
574
+ var SENIORITY_RANK = {
575
+ junior: 0,
576
+ mid: 1,
577
+ senior: 2,
578
+ lead: 3,
579
+ principal: 4
337
580
  };
338
- var LANGUAGE_GROUP = KNOWN_LANGUAGES.join("|");
339
- var LEVEL_GROUP = Object.keys(LEVEL_RANK).sort((a, b) => b.length - a.length).map((l) => l.replace(/\s+/g, "\\s+")).join("|");
340
- var BOUNDARY_START = "(?:^|(?<=[^a-z\xE0-\xFF]))";
341
- var BOUNDARY_END = "(?:$|(?=[^a-z\xE0-\xFF]))";
342
- var LANGUAGE_LEVEL_RE = new RegExp(
343
- `\\b(${LANGUAGE_GROUP})\\b(?:\\s*[\\(:\\-]?\\s*(${BOUNDARY_START}(?:${LEVEL_GROUP})${BOUNDARY_END}|[abc][12]))?`,
344
- "gi"
345
- );
346
- var LEVEL_BEFORE_LANGUAGE_RE = new RegExp(
347
- `${BOUNDARY_START}(${LEVEL_GROUP})${BOUNDARY_END}\\s+(?:in\\s+)?(${LANGUAGE_GROUP})\\b`,
348
- "gi"
349
- );
350
- function canonicalLanguage(name) {
351
- const lower = name.toLowerCase();
352
- return LANGUAGE_ALIASES[lower] ?? lower;
353
- }
354
- function toParsedLanguage(name, level) {
355
- const normalizedLevel = level?.toLowerCase().replace(/\s+/g, " ");
356
- return {
357
- name: canonicalLanguage(name),
358
- level: normalizedLevel,
359
- levelRank: normalizedLevel ? LEVEL_RANK[normalizedLevel] : void 0
360
- };
361
- }
362
- function parseLanguageMentions(text) {
363
- const found = /* @__PURE__ */ new Map();
364
- for (const match of text.matchAll(LANGUAGE_LEVEL_RE)) {
365
- const parsed = toParsedLanguage(match[1], match[2]);
366
- const existing = found.get(parsed.name);
367
- if (!existing || (parsed.levelRank ?? 0) > (existing.levelRank ?? 0)) {
368
- found.set(parsed.name, parsed);
369
- }
370
- }
371
- for (const match of text.matchAll(LEVEL_BEFORE_LANGUAGE_RE)) {
372
- const parsed = toParsedLanguage(match[2], match[1]);
373
- const existing = found.get(parsed.name);
374
- if (!existing || (parsed.levelRank ?? 0) > (existing.levelRank ?? 0)) {
375
- 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
+ }
376
591
  }
377
592
  }
378
- return [...found.values()].sort((a, b) => a.name.localeCompare(b.name));
593
+ return best;
594
+ }
595
+ function normalizeTitle(title) {
596
+ return title.toLowerCase().replace(/[.,/#!$%^&*;:{}=_`~()]/g, " ").replace(/\s+/g, " ").trim();
379
597
  }
380
- function diffLanguages(resumeLanguages, requiredLanguages) {
381
- const byName = new Map(resumeLanguages.map((l) => [l.name, l]));
382
- const matched = [];
383
- const missing = [];
384
- for (const required of requiredLanguages) {
385
- const have = byName.get(required.name);
386
- const requiredRank = required.levelRank ?? 0;
387
- const haveRank = have?.levelRank ?? 0;
388
- if (have && haveRank >= requiredRank) {
389
- matched.push(required);
390
- } else {
391
- missing.push(required);
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;
392
620
  }
393
621
  }
394
- return { matched, missing };
622
+ return matched / keywords.length;
395
623
  }
396
624
 
397
625
  // src/core/parser/jd.parser.ts
@@ -420,14 +648,49 @@ function extractPreferredSkills(lines) {
420
648
  }
421
649
  return preferred;
422
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
+ }
423
679
  var ROLE_NOUN_RE = new RegExp(`(${ROLE_NOUNS.join("|")})`, "gi");
424
680
  function extractRoleKeywords(text) {
425
681
  const roleMatches = text.match(ROLE_NOUN_RE) ?? [];
426
682
  const fallback = roleMatches.length === 0 ? [text.split(/\n/)[0] ?? ""] : [];
427
683
  return unique(tokenize([...roleMatches, ...fallback].join(" ")));
428
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
+ }
429
690
  function extractMinExperience(text) {
430
- 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
+ );
431
694
  if (!match) return void 0;
432
695
  const parsed = Number.parseInt(match[1], 10);
433
696
  return parsed <= 60 ? parsed : void 0;
@@ -460,7 +723,8 @@ function extractSkillExperienceRequirements(lines, skillVocab, aliases) {
460
723
  const record = (rawWord, years) => {
461
724
  if (!Number.isFinite(years) || years <= 0 || years > 60) return;
462
725
  const canonical = normalizeSkill(rawWord, aliases);
463
- if (!skillVocab.has(rawWord.toLowerCase()) && !skillVocab.has(canonical)) return;
726
+ if (!skillVocab.has(rawWord.toLowerCase()) && !skillVocab.has(canonical))
727
+ return;
464
728
  const existing = requirements.get(canonical);
465
729
  if (existing === void 0 || years > existing) {
466
730
  requirements.set(canonical, years);
@@ -471,8 +735,13 @@ function extractSkillExperienceRequirements(lines, skillVocab, aliases) {
471
735
  re.lastIndex = 0;
472
736
  let match;
473
737
  while (match = re.exec(line)) {
474
- const years = Number.parseInt(re === YEARS_BEFORE_SKILL_RE ? match[1] : match[2], 10);
475
- const candidateWords = tokenize(re === YEARS_BEFORE_SKILL_RE ? match[2] : match[1]);
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
+ );
476
745
  for (const word of candidateWords) record(word, years);
477
746
  }
478
747
  }
@@ -494,21 +763,42 @@ function parseJobDescription(jobDescription, config) {
494
763
  skillVocab.add(canonical.toLowerCase());
495
764
  for (const alias of aliases) skillVocab.add(alias.toLowerCase());
496
765
  }
497
- for (const s of config.profile?.mandatorySkills ?? []) skillVocab.add(s.toLowerCase());
498
- 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());
499
770
  const isSkillLike = (t) => {
500
771
  if (skillVocab.has(t)) return true;
501
772
  if (/[.#+]/.test(t) && /[a-z]/.test(t)) return true;
502
- 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));
503
775
  return false;
504
776
  };
505
- const requiredSkillsRaw = extractRequiredSkills(lines).filter(isSkillLike);
506
- const preferredSkillsRaw = extractPreferredSkills(lines).filter(isSkillLike);
507
- const requiredSkills = normalizeSkills(requiredSkillsRaw, config.skillAliases);
508
- 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
+ );
509
794
  const bodyTokens = tokenize(normalizedText).filter(isSkillLike);
510
795
  const roleKeywords = extractRoleKeywords(jobDescription);
511
- const keywords = unique([...requiredSkills, ...preferredSkills, ...roleKeywords, ...bodyTokens]);
796
+ const keywords = unique([
797
+ ...requiredSkills,
798
+ ...preferredSkills,
799
+ ...roleKeywords,
800
+ ...bodyTokens
801
+ ]);
512
802
  const skillExperienceRequirements = extractSkillExperienceRequirements(
513
803
  lines,
514
804
  skillVocab,
@@ -524,12 +814,16 @@ function parseJobDescription(jobDescription, config) {
524
814
  minExperienceYears: extractMinExperience(jobDescription),
525
815
  skillExperienceRequirements,
526
816
  educationRequirements: extractDegreeLevels(jobDescription),
527
- keywordSurfaceForms: collectKeywordSurfaceForms(jobDescription, config.skillAliases),
817
+ keywordSurfaceForms: collectKeywordSurfaceForms(
818
+ jobDescription,
819
+ config.skillAliases
820
+ ),
528
821
  // A language only counts as required if its mention carries a requirement/level cue
529
822
  // or sits in a "Languages:" line — plain references ("our Berlin office") don't count.
530
823
  requiredLanguages: parseLanguageMentions(jobDescription).filter(
531
824
  (lang) => isLanguageRequired(lang, jobDescription)
532
- )
825
+ ),
826
+ seniority: inferSeniority(extractTitleContextLines(jobDescription))
533
827
  };
534
828
  }
535
829
 
@@ -627,7 +921,9 @@ function parseDateRange(text, referenceDate) {
627
921
  }
628
922
  const startToken = parseDateToken(rangeMatch[1]);
629
923
  const endRaw = rangeMatch[2];
630
- 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
+ );
631
927
  const endToken = isPresent ? void 0 : parseDateToken(endRaw);
632
928
  if (!startToken) {
633
929
  return null;
@@ -675,27 +971,92 @@ function sumExperienceYears(ranges) {
675
971
  totalMonths += curEnd - curStart + 1;
676
972
  return Number((totalMonths / 12).toFixed(2));
677
973
  }
678
- 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
+ );
679
978
  return Number((months / 12).toFixed(2));
680
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
+ }
681
1013
 
682
1014
  // src/core/parser/resume.parser.ts
683
1015
  var SECTION_ALIASES = {
684
- 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
+ ],
685
1025
  experience: [
686
1026
  "experience",
687
1027
  "work experience",
688
1028
  "professional experience",
689
1029
  "employment",
1030
+ "work history",
1031
+ "employment history",
690
1032
  "erfahrung",
691
1033
  "berufserfahrung",
692
1034
  "exp\xE9rience",
693
1035
  "exp\xE9rience professionnelle"
694
1036
  ],
695
- skills: ["skills", "technical skills", "technologies", "f\xE4higkeiten", "kenntnisse", "comp\xE9tences"],
696
- 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
+ ],
697
1053
  projects: ["projects", "portfolio", "projekte", "projets"],
698
- certifications: ["certifications", "licenses", "zertifizierungen", "certifications professionnelles"]
1054
+ certifications: [
1055
+ "certifications",
1056
+ "licenses",
1057
+ "zertifizierungen",
1058
+ "certifications professionnelles"
1059
+ ]
699
1060
  };
700
1061
  var STRONG_VERBS = [
701
1062
  "led",
@@ -718,32 +1079,90 @@ var STRONG_VERBS = [
718
1079
  "reduced",
719
1080
  "increased"
720
1081
  ];
721
- var WEAK_VERBS = ["worked", "helped", "performed", "responsible", "assisted", "participated", "involved"];
722
- var TITLE_SENIORITY_PREFIXES = ["senior", "lead", "principal", "staff", "vp", "director"];
723
- var TITLE_MODIFIER_WORDS = ["software", "full\\s*stack", "frontend", "backend"];
724
- var TITLE_PREFIX_WORDS = unique([...TITLE_SENIORITY_PREFIXES, ...TITLE_MODIFIER_WORDS, ...ROLE_NOUNS]);
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
+ ]);
725
1110
  var TITLE_RE = new RegExp(`^(${TITLE_PREFIX_WORDS.join("|")})[^,(-]*`, "i");
726
1111
  var METRIC_RE = /\d|%|\$|\bk\+|\bm\+/i;
727
1112
  function classifyAchievement(line) {
728
1113
  const lower = line.toLowerCase();
729
1114
  const hasMetric = METRIC_RE.test(line);
730
- const hasStrongVerb = STRONG_VERBS.some((verb) => new RegExp(`\\b${verb}\\b`).test(lower));
731
- 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
+ );
732
1121
  if (hasStrongVerb && hasMetric) {
733
- 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
+ };
734
1127
  }
735
1128
  if (hasWeakVerb) {
736
1129
  return { text: line, strength: "weak", reason: "weak verb" };
737
1130
  }
738
1131
  return { text: line, strength: "weak", reason: "no quantified impact" };
739
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;
740
1135
  function detectSection(line) {
741
- 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();
742
1139
  for (const [section, aliases] of Object.entries(SECTION_ALIASES)) {
743
1140
  for (const alias of aliases) {
744
1141
  const safeAlias = escapeRegExp(alias);
745
- const headerPattern = new RegExp(`^${safeAlias}(\\s*:)?$`, "i");
746
- 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])) {
747
1166
  return section;
748
1167
  }
749
1168
  }
@@ -775,12 +1194,42 @@ function extractSections(text) {
775
1194
  flush();
776
1195
  return { sections, detected: unique(detected) };
777
1196
  }
778
- function parseSkills(sectionContent, aliases) {
1197
+ function parseExplicitSkillList(sectionContent) {
779
1198
  if (!sectionContent) return [];
780
1199
  const hasBullets = /[•·‣▪○●◦]/.test(sectionContent);
781
1200
  const normalized = hasBullets ? sectionContent.replace(/\n/g, " ") : sectionContent;
782
- const raw = normalized.split(/[,;\n]|[•·‣▪○●◦]/).map((skill) => skill.trim().replace(/^[-•·‣▪○●◦\s]+|[-•·‣▪○●◦\s]+$/g, "").trim()).filter(Boolean);
783
- 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
+ );
784
1233
  }
785
1234
  function parseActionVerbs(text) {
786
1235
  const words = tokenize(text);
@@ -789,7 +1238,7 @@ function parseActionVerbs(text) {
789
1238
  weak: WEAK_VERBS.filter((verb) => words.includes(verb))
790
1239
  };
791
1240
  }
792
- function parseExperience(sectionContent, referenceDate) {
1241
+ function parseExperience(sectionContent, referenceDate, aliases) {
793
1242
  if (!sectionContent) {
794
1243
  return { entries: [], rangesInMonths: [], jobTitles: [], achievements: [] };
795
1244
  }
@@ -800,18 +1249,18 @@ function parseExperience(sectionContent, referenceDate) {
800
1249
  const achievements = [];
801
1250
  for (const line of lines) {
802
1251
  const range = parseDateRange(line, referenceDate);
803
- const titleMatch = line.match(TITLE_RE);
804
- const title = titleMatch ? titleMatch[0].trim() : void 0;
1252
+ const titleMatch2 = line.match(TITLE_RE);
1253
+ const title = titleMatch2 ? titleMatch2[0].trim() : void 0;
805
1254
  if (range) {
806
1255
  if (title) {
807
1256
  jobTitles.push(title.toLowerCase());
808
- entries.push({ title, dates: range, description: line });
1257
+ entries.push({ title, dates: range, description: line, skills: [] });
809
1258
  } else {
810
1259
  const previous = entries[entries.length - 1];
811
1260
  if (previous && !previous.dates) {
812
1261
  previous.dates = range;
813
1262
  } else {
814
- entries.push({ dates: range });
1263
+ entries.push({ dates: range, skills: [] });
815
1264
  }
816
1265
  }
817
1266
  if (range.durationInMonths) {
@@ -821,7 +1270,11 @@ function parseExperience(sectionContent, referenceDate) {
821
1270
  }
822
1271
  if (title) {
823
1272
  jobTitles.push(title.toLowerCase());
824
- const entry = { title, description: line };
1273
+ const entry = {
1274
+ title,
1275
+ description: line,
1276
+ skills: []
1277
+ };
825
1278
  entries.push(entry);
826
1279
  achievements.push(classifyAchievement(line));
827
1280
  continue;
@@ -832,7 +1285,15 @@ function parseExperience(sectionContent, referenceDate) {
832
1285
  }
833
1286
  achievements.push(classifyAchievement(line));
834
1287
  }
835
- 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
+ };
836
1297
  }
837
1298
  function parseEducation(sectionContent) {
838
1299
  if (!sectionContent) return [];
@@ -843,18 +1304,36 @@ function collectKeywords(text) {
843
1304
  }
844
1305
  var EMAIL_RE = /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/i;
845
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
+ }
846
1319
  function extractContact(text) {
847
1320
  const email = text.match(EMAIL_RE)?.[0];
848
1321
  const phone = text.match(PHONE_RE)?.[0]?.trim();
849
- if (!email && !phone) return void 0;
850
- return { email, phone };
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 };
851
1326
  }
852
1327
  function parseResume(resumeText, config) {
853
1328
  const normalizedText = normalizeWhitespace(resumeText);
854
1329
  const { sections, detected } = extractSections(resumeText);
855
- const skills = parseSkills(sections.skills, config.skillAliases);
1330
+ const skills = parseSkills(sections, config.skillAliases);
856
1331
  const actionVerbs = parseActionVerbs(normalizedText);
857
- const experienceData = parseExperience(sections.experience, config.referenceDate);
1332
+ const experienceData = parseExperience(
1333
+ sections.experience,
1334
+ config.referenceDate,
1335
+ config.skillAliases
1336
+ );
858
1337
  const educationEntries = parseEducation(sections.education);
859
1338
  let totalExperienceYears = sumExperienceYears(
860
1339
  experienceData.entries.map((entry) => entry.dates).filter((range) => Boolean(range))
@@ -867,7 +1346,12 @@ function parseResume(resumeText, config) {
867
1346
  totalExperienceYears = parsed <= 60 ? parsed : 0;
868
1347
  }
869
1348
  }
870
- const requiredSections = ["summary", "experience", "skills", "education"];
1349
+ const requiredSections = [
1350
+ "summary",
1351
+ "experience",
1352
+ "skills",
1353
+ "education"
1354
+ ];
871
1355
  const warnings = [];
872
1356
  const lineCount = splitLines(resumeText).length;
873
1357
  if (resumeText.trim().length < 100) {
@@ -906,6 +1390,9 @@ function parseResume(resumeText, config) {
906
1390
  keywords: collectKeywords(normalizedText),
907
1391
  languages: parseLanguageMentions(resumeText),
908
1392
  contact,
1393
+ seniority: inferSeniority(experienceData.jobTitles),
1394
+ employmentGaps: detectEmploymentGaps(experienceData.entries),
1395
+ formatting: detectFormatting(resumeText),
909
1396
  warnings
910
1397
  };
911
1398
  }
@@ -916,7 +1403,12 @@ var RuleEngine = class {
916
1403
  this.config = config;
917
1404
  }
918
1405
  applySectionPenalties(resume) {
919
- const requiredSections = ["summary", "experience", "skills", "education"];
1406
+ const requiredSections = [
1407
+ "summary",
1408
+ "experience",
1409
+ "skills",
1410
+ "education"
1411
+ ];
920
1412
  let totalPenalty = 0;
921
1413
  const warnings = [];
922
1414
  const penaltyBySection = {
@@ -942,14 +1434,16 @@ var RuleEngine = class {
942
1434
  const sectionResult = this.applySectionPenalties(input.resume);
943
1435
  totalPenalty += sectionResult.totalPenalty;
944
1436
  warnings.push(...sectionResult.warnings);
945
- if (containsTableLikeStructure(input.resume.raw)) {
1437
+ if (input.resume.formatting.hasTables) {
946
1438
  totalPenalty += 8;
947
1439
  warnings.push("Detected table-like or columnar formatting (penalty 8)");
948
1440
  }
949
1441
  if (input.overusedKeywords && input.overusedKeywords.length > 0) {
950
1442
  const penalty = input.overusedKeywords.length * this.config.keywordDensity.overusePenalty;
951
1443
  totalPenalty += penalty;
952
- 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
+ );
953
1447
  }
954
1448
  if (input.resume.detectedSections.length < 3) {
955
1449
  totalPenalty += 5;
@@ -990,9 +1484,31 @@ var RuleEngine = class {
990
1484
  // src/core/scoring/scorer.ts
991
1485
  var REQUIRED_SKILL_WEIGHT = 0.7;
992
1486
  var OPTIONAL_SKILL_WEIGHT = 0.3;
993
- var EXPERIENCE_YEARS_WEIGHT = 0.75;
994
- var EXPERIENCE_ROLE_WEIGHT = 0.25;
995
- 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
+ };
996
1512
  function emptyCategoryBuckets() {
997
1513
  const buckets = {};
998
1514
  for (const category of ALL_CATEGORIES) {
@@ -1000,18 +1516,32 @@ function emptyCategoryBuckets() {
1000
1516
  }
1001
1517
  return buckets;
1002
1518
  }
1003
- function scoreSkills(resume, job, config) {
1519
+ function scoreSkills(resume, job, config, matchOptions) {
1004
1520
  const profileRequired = config.profile?.mandatorySkills ?? [];
1005
1521
  const profileOptional = config.profile?.optionalSkills ?? [];
1006
1522
  const required = new Set(
1007
- normalizeSkills([...job.requiredSkills, ...profileRequired], config.skillAliases)
1523
+ normalizeSkills(
1524
+ [...job.requiredSkills, ...profileRequired],
1525
+ config.skillAliases,
1526
+ matchOptions
1527
+ )
1008
1528
  );
1009
1529
  const optional = new Set(
1010
- 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)
1011
1544
  );
1012
- const resumeSkills = new Set(normalizeSkills(resume.skills, config.skillAliases));
1013
- const matchedRequired = [...required].filter((skill) => resumeSkills.has(skill));
1014
- const matchedOptional = [...optional].filter((skill) => resumeSkills.has(skill));
1015
1545
  const requiredCoverage = required.size === 0 ? 1 : matchedRequired.length / required.size;
1016
1546
  const optionalCoverage = optional.size === 0 ? 1 : matchedOptional.length / optional.size;
1017
1547
  const score = clamp(
@@ -1023,30 +1553,45 @@ function scoreSkills(resume, job, config) {
1023
1553
  const missing = [...required].filter((skill) => !resumeSkills.has(skill)).sort();
1024
1554
  return { score, matched, missing };
1025
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
+ }
1026
1562
  function scoreExperience(resume, job, config) {
1563
+ const seniorityMatch = computeSeniorityMatch(resume, job);
1027
1564
  const requiredYears = job.minExperienceYears ?? config.profile?.minExperience ?? 0;
1028
1565
  if (!requiredYears) {
1029
- return { score: 100, missingYears: 0 };
1566
+ return { score: 100, missingYears: 0, seniorityMatch };
1030
1567
  }
1031
1568
  const yearCoverage = clamp(resume.totalExperienceYears / requiredYears, 0, 2);
1032
1569
  const yearsComponent = clamp(yearCoverage, 0, 1) * EXPERIENCE_YEARS_WEIGHT;
1033
- const jobRoleSet = new Set(job.roleKeywords.map((value) => value.toLowerCase()));
1034
- const resumeTitleTokens = new Set(resume.jobTitles.flatMap((t) => tokenize(t)));
1035
- const matchedRoles = job.roleKeywords.filter((rk) => resumeTitleTokens.has(rk.toLowerCase()));
1036
- const titleCoverage = jobRoleSet.size === 0 ? 1 : matchedRoles.length / jobRoleSet.size;
1570
+ const titleCoverage = job.roleKeywords.length === 0 ? 1 : titleMatch(resume.jobTitles, job.roleKeywords);
1037
1571
  const roleComponent = clamp(titleCoverage, 0, 1) * EXPERIENCE_ROLE_WEIGHT;
1038
- 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
+ );
1039
1578
  const missingYears = Math.max(requiredYears - resume.totalExperienceYears, 0);
1040
- return { score, missingYears: Number(missingYears.toFixed(2)) };
1579
+ return {
1580
+ score,
1581
+ missingYears: Number(missingYears.toFixed(2)),
1582
+ seniorityMatch
1583
+ };
1041
1584
  }
1042
1585
  function keywordWeightOf(keyword, requiredSet, preferredSet, jdFrequencies) {
1043
1586
  const base = requiredSet.has(keyword) ? 3 : preferredSet.has(keyword) ? 2 : 1;
1044
1587
  const freqBonus = Math.min((jdFrequencies[keyword] ?? 1) - 1, 3) * 0.25;
1045
1588
  return base + freqBonus;
1046
1589
  }
1047
- function scoreKeywords(resume, job, config) {
1590
+ function scoreKeywords(resume, job, config, matchOptions) {
1048
1591
  const jobKeywordSet = new Set(
1049
- job.keywords.map((k) => normalizeSkill(k, config.skillAliases))
1592
+ job.keywords.map(
1593
+ (k) => normalizeSkill(k, config.skillAliases, matchOptions)
1594
+ )
1050
1595
  );
1051
1596
  if (jobKeywordSet.size === 0) {
1052
1597
  return {
@@ -1059,20 +1604,32 @@ function scoreKeywords(resume, job, config) {
1059
1604
  };
1060
1605
  }
1061
1606
  const resumeTokens = tokenize(resume.normalizedText).map(
1062
- (t) => normalizeSkill(t, config.skillAliases)
1607
+ (t) => normalizeSkill(t, config.skillAliases, matchOptions)
1063
1608
  );
1064
1609
  const resumeTokenSet = new Set(resumeTokens);
1065
1610
  const resumeFrequencies = countFrequencies(resumeTokens);
1066
1611
  const requiredSet = new Set(job.requiredSkills);
1067
1612
  const preferredSet = new Set(job.preferredSkills);
1068
1613
  const jdFrequencies = countFrequencies(
1069
- tokenize(job.normalizedText).map((t) => normalizeSkill(t, config.skillAliases))
1614
+ tokenize(job.normalizedText).map(
1615
+ (t) => normalizeSkill(t, config.skillAliases, matchOptions)
1616
+ )
1070
1617
  );
1071
1618
  const weightOf = (keyword) => keywordWeightOf(keyword, requiredSet, preferredSet, jdFrequencies);
1072
- const matchedKeywords = [...jobKeywordSet].filter((keyword) => resumeTokenSet.has(keyword));
1073
- const missingKeywords = [...jobKeywordSet].filter((keyword) => !resumeTokenSet.has(keyword));
1074
- const totalWeight = [...jobKeywordSet].reduce((sum, keyword) => sum + weightOf(keyword), 0);
1075
- 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
+ );
1076
1633
  const score = clamp(matchedWeight / totalWeight * 100, 0, 100);
1077
1634
  const totalTokens = resumeTokens.length || 1;
1078
1635
  const overusedKeywords = matchedKeywords.filter((keyword) => {
@@ -1109,13 +1666,37 @@ function scoreKeywords(resume, job, config) {
1109
1666
  keywordWeights
1110
1667
  };
1111
1668
  }
1112
- function computeSkillExperienceGaps(resume, job, config) {
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) {
1113
1687
  if (job.skillExperienceRequirements.length === 0) return [];
1114
- const resumeSkills = new Set(normalizeSkills(resume.skills, config.skillAliases));
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
+ );
1115
1694
  const gaps = [];
1116
1695
  for (const { skill, years } of job.skillExperienceRequirements) {
1117
- if (resumeSkills.has(skill) && resume.totalExperienceYears < years) {
1118
- gaps.push({ skill, requiredYears: years, resumeYears: resume.totalExperienceYears });
1696
+ if (!resumeSkills.has(skill)) continue;
1697
+ const resumeYears = yearsBySkill.get(skill) ?? 0;
1698
+ if (resumeYears < years) {
1699
+ gaps.push({ skill, requiredYears: years, resumeYears });
1119
1700
  }
1120
1701
  }
1121
1702
  return gaps.sort((a, b) => a.skill.localeCompare(b.skill));
@@ -1124,36 +1705,120 @@ function scoreEducation(resume, job) {
1124
1705
  if (job.educationRequirements.length === 0) {
1125
1706
  return 100;
1126
1707
  }
1127
- const resumeDegreeLevels = extractDegreeLevels(resume.educationEntries.join(" "));
1128
- const matched = job.educationRequirements.filter(
1129
- (requirement) => resumeDegreeLevels.includes(requirement)
1708
+ const resumeDegreeLevels = extractDegreeLevels(
1709
+ resume.educationEntries.join(" ")
1130
1710
  );
1131
- if (matched.length === 0) {
1711
+ if (resumeDegreeLevels.length === 0) {
1132
1712
  return 0;
1133
1713
  }
1134
- 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
+ };
1135
1782
  }
1136
1783
  function calculateScore(resume, job, config) {
1137
- 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);
1138
1789
  const experienceResult = scoreExperience(resume, job, config);
1139
- const keywordResult = scoreKeywords(resume, job, config);
1790
+ const keywordResult = scoreKeywords(resume, job, config, matchOptions);
1140
1791
  const educationScore = scoreEducation(resume, job);
1792
+ const parseabilityResult = scoreParseability(
1793
+ resume.formatting,
1794
+ resume.contact,
1795
+ resume.detectedSections
1796
+ );
1141
1797
  const breakdown = {
1142
1798
  skills: skillsResult.score,
1143
1799
  experience: experienceResult.score,
1144
1800
  keywords: keywordResult.score,
1801
+ parseability: parseabilityResult.score,
1145
1802
  education: educationScore
1146
1803
  };
1147
- 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;
1148
1805
  const achievementStrength = {
1149
1806
  strong: resume.achievements.filter((a) => a.strength === "strong").length,
1150
1807
  weak: resume.achievements.filter((a) => a.strength === "weak").length
1151
1808
  };
1152
- const { matched: matchedLanguages, missing: missingLanguages } = diffLanguages(
1153
- resume.languages,
1154
- 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
1155
1821
  );
1156
- const skillExperienceGaps = computeSkillExperienceGaps(resume, job, config);
1157
1822
  return {
1158
1823
  score: clamp(Number(weightedScore.toFixed(2)), 0, 100),
1159
1824
  breakdown,
@@ -1176,7 +1841,11 @@ function calculateScore(resume, job, config) {
1176
1841
  parsedExperienceYears: 0,
1177
1842
  experienceEntries: [],
1178
1843
  missingExperienceYears: experienceResult.missingYears,
1179
- educationScore
1844
+ educationScore,
1845
+ parseabilityReport: parseabilityResult.report,
1846
+ employmentGaps: resume.employmentGaps,
1847
+ seniorityMatch: experienceResult.seniorityMatch,
1848
+ perSkillExperience
1180
1849
  };
1181
1850
  }
1182
1851
 
@@ -1184,109 +1853,499 @@ function calculateScore(resume, job, config) {
1184
1853
  var defaultKeywordRegistry = [
1185
1854
  // languages / frameworks
1186
1855
  // ponytail: "node" split from javascript — Node.js runtime !== JS language
1187
- { canonical: "javascript", aliases: ["js"], category: "technical" },
1856
+ {
1857
+ canonical: "javascript",
1858
+ aliases: ["js", "ecmascript", "es6"],
1859
+ category: "technical"
1860
+ },
1188
1861
  { canonical: "node", aliases: ["node.js", "nodejs"], category: "technical" },
1189
1862
  { canonical: "typescript", aliases: ["ts"], category: "technical" },
1190
- { canonical: "react", aliases: ["reactjs", "react.js"], category: "technical" },
1191
- { 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
+ },
1192
1873
  { canonical: "vue", aliases: ["vue.js", "vuejs"], category: "technical" },
1193
- { canonical: "svelte", aliases: [], category: "technical" },
1874
+ { canonical: "svelte", aliases: ["sveltekit"], category: "technical" },
1194
1875
  { canonical: "next.js", aliases: ["nextjs"], category: "technical" },
1876
+ { canonical: "nuxt.js", aliases: ["nuxtjs", "nuxt"], category: "technical" },
1877
+ { canonical: "remix", aliases: [], category: "technical" },
1195
1878
  { canonical: "c++", aliases: ["cpp"], category: "technical" },
1196
1879
  { canonical: "c#", aliases: ["csharp", ".net"], category: "technical" },
1197
1880
  { canonical: "java", aliases: [], category: "technical" },
1198
1881
  { canonical: "python", aliases: ["py"], category: "technical" },
1199
1882
  { canonical: "go", aliases: ["golang"], category: "technical" },
1200
1883
  { canonical: "rust", aliases: [], category: "technical" },
1201
- { canonical: "ruby", aliases: ["ruby on rails", "rails"], category: "technical" },
1884
+ {
1885
+ canonical: "ruby",
1886
+ aliases: ["ruby on rails", "rails"],
1887
+ category: "technical"
1888
+ },
1202
1889
  { canonical: "php", aliases: [], category: "technical" },
1203
- { canonical: "swift", aliases: [], category: "technical" },
1890
+ { canonical: "swift", aliases: ["swiftui"], category: "technical" },
1204
1891
  { canonical: "kotlin", aliases: [], category: "technical" },
1205
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" },
1206
1908
  { canonical: "html", aliases: ["html5"], category: "technical" },
1207
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" },
1208
1919
  { canonical: "ios development", aliases: ["ios"], category: "technical" },
1209
- { canonical: "android development", aliases: ["android"], category: "technical" },
1920
+ {
1921
+ canonical: "android development",
1922
+ aliases: ["android"],
1923
+ category: "technical"
1924
+ },
1210
1925
  { canonical: "react native", aliases: [], category: "technical" },
1211
1926
  { canonical: "flutter", aliases: [], category: "technical" },
1927
+ { canonical: "xamarin", aliases: [], category: "technical" },
1212
1928
  { canonical: "machine learning", aliases: ["ml"], category: "technical" },
1213
- { canonical: "deep learning", aliases: [], category: "technical" },
1214
- { 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" },
1215
1994
  // tools / platforms / infra
1216
- { 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" },
1217
2020
  { canonical: "graphql", aliases: ["gql"], category: "tool" },
1218
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" },
1219
2029
  { canonical: "azure", aliases: ["microsoft azure"], category: "tool" },
1220
- { canonical: "gcp", aliases: ["google cloud", "google cloud platform"], category: "tool" },
1221
- { 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
+ },
1222
2044
  { canonical: "kubernetes", aliases: ["k8s"], category: "tool" },
2045
+ { canonical: "helm", aliases: [], category: "tool" },
1223
2046
  { canonical: "terraform", aliases: [], category: "tool" },
2047
+ { canonical: "pulumi", aliases: [], category: "tool" },
1224
2048
  { canonical: "ansible", aliases: [], category: "tool" },
2049
+ { canonical: "puppet", aliases: [], category: "tool" },
2050
+ { canonical: "chef", aliases: [], category: "tool" },
1225
2051
  { canonical: "jenkins", aliases: [], category: "tool" },
1226
- { 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
+ },
1227
2061
  { canonical: "jira", aliases: [], category: "tool" },
1228
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" },
1229
2070
  { canonical: "pytorch", aliases: ["torch"], category: "tool" },
1230
2071
  { canonical: "tensorflow", aliases: ["tf"], category: "tool" },
2072
+ { canonical: "keras", aliases: [], category: "tool" },
1231
2073
  { canonical: "scikit-learn", aliases: ["sklearn"], category: "tool" },
1232
2074
  { canonical: "pandas", aliases: [], category: "tool" },
1233
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" },
1234
2084
  { canonical: "fastapi", aliases: [], category: "tool" },
1235
2085
  { canonical: "flask", aliases: [], category: "tool" },
1236
2086
  { canonical: "django", aliases: [], category: "tool" },
1237
- { 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" },
1238
2096
  { canonical: "redis", aliases: [], category: "tool" },
2097
+ { canonical: "memcached", aliases: [], category: "tool" },
1239
2098
  { canonical: "elasticsearch", aliases: ["elastic"], category: "tool" },
1240
- { 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" },
1241
2117
  { canonical: "tableau", aliases: [], category: "tool" },
1242
2118
  { canonical: "power bi", aliases: ["powerbi"], category: "tool" },
1243
- { 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" },
1244
2127
  { canonical: "salesforce", aliases: [], category: "tool" },
1245
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" },
1246
2133
  { canonical: "sap", aliases: [], category: "tool" },
1247
2134
  { canonical: "quickbooks", aliases: [], category: "tool" },
2135
+ { canonical: "netsuite", aliases: [], category: "tool" },
1248
2136
  { canonical: "workday", aliases: [], category: "tool" },
1249
2137
  { canonical: "zendesk", aliases: [], category: "tool" },
1250
2138
  { canonical: "servicenow", aliases: [], category: "tool" },
2139
+ { canonical: "freshdesk", aliases: [], category: "tool" },
1251
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" },
1252
2144
  { canonical: "photoshop", aliases: ["adobe photoshop"], category: "tool" },
1253
- { 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" },
1254
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" },
1255
2165
  // engineering concepts
1256
2166
  { canonical: "accessibility", aliases: ["a11y"], category: "concept" },
1257
2167
  { canonical: "frontend", aliases: ["front-end"], category: "concept" },
1258
2168
  { canonical: "backend", aliases: ["back-end"], category: "concept" },
1259
- { canonical: "security", aliases: ["cybersecurity"], category: "concept" },
1260
- { 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" },
1261
2205
  { canonical: "microservices", aliases: [], category: "concept" },
1262
- { 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" },
1263
2223
  { canonical: "kanban", aliases: [], category: "concept" },
2224
+ { canonical: "waterfall", aliases: [], category: "concept" },
2225
+ { canonical: "sprint planning", aliases: [], category: "concept" },
1264
2226
  { canonical: "blockchain", aliases: [], category: "concept" },
2227
+ { canonical: "smart contracts", aliases: [], category: "concept" },
1265
2228
  { canonical: "devops", aliases: [], category: "concept" },
1266
- { canonical: "ci/cd", aliases: ["continuous integration", "continuous deployment"], category: "concept" },
1267
- { 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" },
1268
2254
  { canonical: "design patterns", aliases: [], category: "concept" },
1269
2255
  { canonical: "data structures", aliases: [], category: "concept" },
1270
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" },
1271
2263
  { canonical: "cloud computing", aliases: [], category: "concept" },
2264
+ { canonical: "cloud migration", aliases: [], category: "concept" },
1272
2265
  { canonical: "system design", aliases: [], category: "concept" },
1273
- { 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" },
1274
2282
  { canonical: "ux design", aliases: ["user experience"], category: "concept" },
1275
- { 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" },
1276
2293
  { canonical: "project management", aliases: [], category: "concept" },
2294
+ { canonical: "program management", aliases: [], category: "concept" },
1277
2295
  { canonical: "change management", aliases: [], category: "concept" },
1278
2296
  { canonical: "risk management", aliases: [], category: "concept" },
1279
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" },
1280
2314
  // product / data domain
1281
- { canonical: "roadmap", aliases: [], category: "domain" },
2315
+ { canonical: "roadmap", aliases: ["product roadmap"], category: "domain" },
1282
2316
  { canonical: "stakeholder management", aliases: [], category: "domain" },
1283
2317
  { canonical: "prioritization", aliases: [], category: "domain" },
1284
2318
  { canonical: "a/b testing", aliases: ["ab testing"], category: "domain" },
1285
2319
  { canonical: "analytics", aliases: [], category: "domain" },
1286
2320
  { canonical: "statistics", aliases: ["stats"], category: "domain" },
1287
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
+ },
1288
2346
  // finance / accounting domain
1289
2347
  { canonical: "financial analysis", aliases: [], category: "domain" },
2348
+ { canonical: "financial modeling", aliases: [], category: "domain" },
1290
2349
  { canonical: "budgeting", aliases: [], category: "domain" },
1291
2350
  { canonical: "forecasting", aliases: [], category: "domain" },
1292
2351
  { canonical: "bookkeeping", aliases: [], category: "domain" },
@@ -1296,78 +2355,260 @@ var defaultKeywordRegistry = [
1296
2355
  { canonical: "auditing", aliases: ["audit"], category: "domain" },
1297
2356
  { canonical: "tax preparation", aliases: [], category: "domain" },
1298
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
+ },
1299
2385
  // sales / account management domain
1300
2386
  { canonical: "lead generation", aliases: [], category: "domain" },
1301
2387
  { canonical: "account management", aliases: [], category: "domain" },
1302
- { canonical: "crm", aliases: ["customer relationship management"], category: "domain" },
1303
- { 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
+ },
1304
2398
  { canonical: "cold calling", aliases: [], category: "domain" },
1305
2399
  { canonical: "upselling", aliases: ["cross-selling"], category: "domain" },
1306
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" },
1307
2411
  // human resources domain
1308
- { canonical: "recruiting", aliases: ["talent acquisition"], category: "domain" },
2412
+ {
2413
+ canonical: "recruiting",
2414
+ aliases: ["talent acquisition"],
2415
+ category: "domain"
2416
+ },
1309
2417
  { canonical: "onboarding", aliases: [], category: "domain" },
1310
2418
  { canonical: "employee relations", aliases: [], category: "domain" },
1311
2419
  { canonical: "benefits administration", aliases: [], category: "domain" },
1312
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
+ },
1313
2437
  // healthcare domain
1314
2438
  { canonical: "patient care", aliases: [], category: "domain" },
1315
2439
  { canonical: "clinical documentation", aliases: [], category: "domain" },
1316
2440
  { canonical: "hipaa", aliases: [], category: "domain" },
1317
- { canonical: "electronic health records", aliases: ["ehr", "emr"], category: "domain" },
2441
+ {
2442
+ canonical: "electronic health records",
2443
+ aliases: ["ehr", "emr"],
2444
+ category: "domain"
2445
+ },
1318
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" },
1319
2455
  // legal domain
1320
2456
  { canonical: "contract review", aliases: [], category: "domain" },
1321
2457
  { canonical: "legal research", aliases: [], category: "domain" },
1322
2458
  { canonical: "litigation", aliases: [], category: "domain" },
1323
- { canonical: "regulatory compliance", aliases: ["compliance"], category: "domain" },
2459
+ {
2460
+ canonical: "regulatory compliance",
2461
+ aliases: ["compliance"],
2462
+ category: "domain"
2463
+ },
1324
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" },
1325
2478
  // education domain
1326
2479
  { canonical: "curriculum development", aliases: [], category: "domain" },
1327
2480
  { canonical: "lesson planning", aliases: [], category: "domain" },
1328
2481
  { canonical: "classroom management", aliases: [], category: "domain" },
1329
2482
  { canonical: "instructional design", aliases: [], category: "domain" },
2483
+ { canonical: "student assessment", aliases: [], category: "domain" },
2484
+ { canonical: "e-learning", aliases: ["elearning"], category: "domain" },
1330
2485
  // operations / supply chain domain
1331
- { canonical: "supply chain management", aliases: ["supply chain"], category: "domain" },
2486
+ {
2487
+ canonical: "supply chain management",
2488
+ aliases: ["supply chain"],
2489
+ category: "domain"
2490
+ },
1332
2491
  { canonical: "inventory management", aliases: [], category: "domain" },
1333
2492
  { canonical: "procurement", aliases: [], category: "domain" },
1334
2493
  { canonical: "vendor management", aliases: [], category: "domain" },
1335
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" },
1336
2500
  // customer service domain
1337
- { canonical: "customer support", aliases: ["customer service"], category: "domain" },
2501
+ {
2502
+ canonical: "customer support",
2503
+ aliases: ["customer service"],
2504
+ category: "domain"
2505
+ },
1338
2506
  { canonical: "technical support", aliases: [], category: "domain" },
1339
2507
  { canonical: "conflict resolution", aliases: [], category: "domain" },
2508
+ { canonical: "customer success", aliases: [], category: "domain" },
2509
+ { canonical: "help desk", aliases: ["helpdesk"], category: "domain" },
1340
2510
  // soft skills
1341
2511
  { canonical: "communication", aliases: [], category: "soft" },
2512
+ { canonical: "written communication", aliases: [], category: "soft" },
2513
+ { canonical: "verbal communication", aliases: [], category: "soft" },
1342
2514
  { canonical: "leadership", aliases: [], category: "soft" },
2515
+ { canonical: "team leadership", aliases: [], category: "soft" },
1343
2516
  { canonical: "teamwork", aliases: ["collaboration"], category: "soft" },
1344
- { 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
+ },
1345
2527
  { canonical: "adaptability", aliases: ["flexibility"], category: "soft" },
1346
2528
  { canonical: "time management", aliases: [], category: "soft" },
1347
2529
  { canonical: "critical thinking", aliases: [], category: "soft" },
1348
2530
  { canonical: "creativity", aliases: [], category: "soft" },
1349
2531
  { canonical: "attention to detail", aliases: [], category: "soft" },
1350
- { canonical: "decision making", aliases: ["decision-making"], category: "soft" },
2532
+ {
2533
+ canonical: "decision making",
2534
+ aliases: ["decision-making"],
2535
+ category: "soft"
2536
+ },
1351
2537
  { canonical: "emotional intelligence", aliases: [], category: "soft" },
1352
2538
  { canonical: "negotiation", aliases: [], category: "soft" },
1353
- { canonical: "organization", aliases: ["organizational skills"], category: "soft" },
1354
- { 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
+ },
1355
2549
  { canonical: "mentoring", aliases: ["coaching"], category: "soft" },
1356
2550
  { canonical: "interpersonal skills", aliases: [], category: "soft" },
1357
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" },
1358
2564
  // marketing
1359
- { 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
+ },
1360
2575
  { canonical: "branding", aliases: ["brand strategy"], category: "marketing" },
1361
2576
  { canonical: "campaign management", aliases: [], category: "marketing" },
1362
2577
  { canonical: "content marketing", aliases: [], category: "marketing" },
1363
- { 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
+ },
1364
2584
  { canonical: "email marketing", aliases: [], category: "marketing" },
1365
2585
  { canonical: "digital marketing", aliases: [], category: "marketing" },
2586
+ {
2587
+ canonical: "growth marketing",
2588
+ aliases: ["growth hacking"],
2589
+ category: "marketing"
2590
+ },
1366
2591
  { canonical: "copywriting", aliases: [], category: "marketing" },
1367
2592
  { canonical: "market research", aliases: [], category: "marketing" },
1368
- { canonical: "ppc", aliases: ["pay-per-click", "google ads"], category: "marketing" },
1369
- { canonical: "conversion rate optimization", aliases: ["cro"], category: "marketing" },
1370
- { 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" }
1371
2612
  ];
1372
2613
  var defaultSkillAliases = deriveSkillAliases(defaultKeywordRegistry);
1373
2614
  var softwareEngineerProfile = {
@@ -1396,10 +2637,11 @@ var defaultProfiles = [
1396
2637
 
1397
2638
  // src/core/scoring/weights.ts
1398
2639
  var DEFAULT_WEIGHTS = {
1399
- skills: 0.3,
1400
- experience: 0.3,
2640
+ skills: 0.25,
2641
+ experience: 0.2,
1401
2642
  keywords: 0.25,
1402
- education: 0.15
2643
+ parseability: 0.2,
2644
+ education: 0.1
1403
2645
  };
1404
2646
  var DEFAULT_KEYWORD_DENSITY = {
1405
2647
  min: 25e-4,
@@ -1411,18 +2653,19 @@ var DEFAULT_SECTION_PENALTIES = {
1411
2653
  missingExperience: 10,
1412
2654
  missingSkills: 8,
1413
2655
  missingEducation: 6,
1414
- // Warning-only by defaultsee determinism note in the contact/parseability plan step;
1415
- // callers who want it to actually move the score can override via config.sectionPenalties.
1416
- missingContact: 0
2656
+ // Raised from 0 (warning-only) in v1a 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
1417
2659
  };
1418
2660
  function normalizeWeights(weights) {
1419
- const total = weights.skills + weights.experience + weights.keywords + weights.education;
2661
+ const total = weights.skills + weights.experience + weights.keywords + weights.parseability + weights.education;
1420
2662
  if (total === 0) {
1421
- const equal = 1 / 4;
2663
+ const equal = 1 / 5;
1422
2664
  return {
1423
2665
  skills: equal,
1424
2666
  experience: equal,
1425
2667
  keywords: equal,
2668
+ parseability: equal,
1426
2669
  education: equal,
1427
2670
  normalizedTotal: 1
1428
2671
  };
@@ -1431,6 +2674,7 @@ function normalizeWeights(weights) {
1431
2674
  skills: weights.skills / total,
1432
2675
  experience: weights.experience / total,
1433
2676
  keywords: weights.keywords / total,
2677
+ parseability: weights.parseability / total,
1434
2678
  education: weights.education / total,
1435
2679
  normalizedTotal: 1
1436
2680
  };
@@ -1440,12 +2684,19 @@ function resolveConfig(config = {}) {
1440
2684
  skills: config.weights?.skills ?? DEFAULT_WEIGHTS.skills,
1441
2685
  experience: config.weights?.experience ?? DEFAULT_WEIGHTS.experience,
1442
2686
  keywords: config.weights?.keywords ?? DEFAULT_WEIGHTS.keywords,
2687
+ parseability: config.weights?.parseability ?? DEFAULT_WEIGHTS.parseability,
1443
2688
  education: config.weights?.education ?? DEFAULT_WEIGHTS.education
1444
2689
  };
1445
- const keywordRegistry = mergeKeywordRegistries(defaultKeywordRegistry, config.keywordRegistry ?? []);
2690
+ const keywordRegistry = mergeKeywordRegistries(
2691
+ defaultKeywordRegistry,
2692
+ config.keywordRegistry ?? []
2693
+ );
1446
2694
  const resolved = {
1447
2695
  weights: normalizeWeights(weights),
1448
- skillAliases: { ...deriveSkillAliases(keywordRegistry), ...config.skillAliases ?? {} },
2696
+ skillAliases: {
2697
+ ...deriveSkillAliases(keywordRegistry),
2698
+ ...config.skillAliases ?? {}
2699
+ },
1449
2700
  keywordRegistry,
1450
2701
  categoryIndex: buildCategoryIndex(keywordRegistry),
1451
2702
  profile: config.profile ?? softwareEngineerProfile,
@@ -1456,6 +2707,13 @@ function resolveConfig(config = {}) {
1456
2707
  ...config.sectionPenalties ?? {}
1457
2708
  },
1458
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
+ },
1459
2717
  referenceDate: config.referenceDate ? new Date(config.referenceDate) : void 0
1460
2718
  };
1461
2719
  return resolved;
@@ -1468,13 +2726,17 @@ function formatList(values, max = 6) {
1468
2726
  return trimmed.join(", ") + (uniqueValues.length > max ? "..." : "");
1469
2727
  }
1470
2728
  function buildAliasReplacementSuggestions(resume, job, config) {
1471
- 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
+ );
1472
2732
  const replacements = [];
1473
2733
  for (const token of unique(tokenize(resume.normalizedText))) {
1474
2734
  const canonical = normalizeSkill(token, config.skillAliases);
1475
2735
  const jdSurface = job.keywordSurfaceForms[canonical];
1476
2736
  if (jdSurface && jobKeywordSet.has(canonical) && jdSurface.toLowerCase() !== token.toLowerCase()) {
1477
- 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
+ );
1478
2740
  }
1479
2741
  }
1480
2742
  return unique(replacements).slice(0, 5);
@@ -1482,7 +2744,10 @@ function buildAliasReplacementSuggestions(resume, job, config) {
1482
2744
  var SuggestionEngine = class {
1483
2745
  generate(input) {
1484
2746
  const suggestions = [];
1485
- const warnings = [...input.ruleWarnings, ...input.resume.warnings];
2747
+ const warnings = [
2748
+ ...input.ruleWarnings,
2749
+ ...input.resume.warnings
2750
+ ];
1486
2751
  if (input.score.missingSkills.length > 0) {
1487
2752
  suggestions.push(
1488
2753
  `Highlight these required skills: ${formatList(input.score.missingSkills)}`
@@ -1493,7 +2758,9 @@ var SuggestionEngine = class {
1493
2758
  `Incorporate job-specific keywords: ${formatList(input.score.missingKeywords)}`
1494
2759
  );
1495
2760
  }
1496
- suggestions.push(...buildAliasReplacementSuggestions(input.resume, input.job, input.config));
2761
+ suggestions.push(
2762
+ ...buildAliasReplacementSuggestions(input.resume, input.job, input.config)
2763
+ );
1497
2764
  if (input.score.overusedKeywords.length > 0) {
1498
2765
  suggestions.push(
1499
2766
  `Avoid keyword stuffing for: ${formatList(input.score.overusedKeywords)}`
@@ -1504,7 +2771,7 @@ var SuggestionEngine = class {
1504
2771
  `Clarify at least ${input.job.minExperienceYears ?? input.score.missingExperienceYears} years of relevant experience with quantified achievements.`
1505
2772
  );
1506
2773
  }
1507
- if (input.job.educationRequirements.length > 0 && input.score.educationScore === 0) {
2774
+ if (input.job.educationRequirements.length > 0 && input.score.educationScore < 60) {
1508
2775
  suggestions.push(
1509
2776
  `State your education credentials matching: ${formatList(input.job.educationRequirements)}`
1510
2777
  );
@@ -1523,7 +2790,9 @@ var SuggestionEngine = class {
1523
2790
  const formatted = input.score.missingLanguages.map((l) => l.level ? `${l.name} (${l.level})` : l.name).join(", ");
1524
2791
  suggestions.push(`Mention your proficiency in: ${formatted}`);
1525
2792
  }
1526
- const weakAchievement = input.resume.achievements.find((a) => a.strength === "weak");
2793
+ const weakAchievement = input.resume.achievements.find(
2794
+ (a) => a.strength === "weak"
2795
+ );
1527
2796
  if (weakAchievement) {
1528
2797
  suggestions.push(
1529
2798
  `Strengthen "${weakAchievement.text}" \u2014 add scope/metrics, e.g. "Built and maintained scalable services handling 500k+ requests/day."`
@@ -1544,6 +2813,24 @@ var SuggestionEngine = class {
1544
2813
  `The role asks for ${gap.requiredYears}+ years of ${gap.skill}; make that duration explicit in your experience section.`
1545
2814
  );
1546
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
+ }
1547
2834
  return { suggestions, warnings };
1548
2835
  }
1549
2836
  };
@@ -1591,7 +2878,10 @@ var LLMBudgetManager = class {
1591
2878
  callsUsed: this.callCount,
1592
2879
  callsRemaining: Math.max(0, this.limits.maxCalls - this.callCount),
1593
2880
  tokensUsed: this.totalTokensUsed,
1594
- tokensRemaining: Math.max(0, this.limits.maxTotalTokens - this.totalTokensUsed),
2881
+ tokensRemaining: Math.max(
2882
+ 0,
2883
+ this.limits.maxTotalTokens - this.totalTokensUsed
2884
+ ),
1595
2885
  totalCalls: this.limits.maxCalls,
1596
2886
  totalTokens: this.limits.maxTotalTokens
1597
2887
  };
@@ -1613,7 +2903,8 @@ var LLMBudgetManager = class {
1613
2903
 
1614
2904
  // src/llm/validation.ts
1615
2905
  function validateJsonSchema(data, schema) {
1616
- if (schema.type !== "object" || typeof data !== "object" || data === null) return false;
2906
+ if (schema.type !== "object" || typeof data !== "object" || data === null)
2907
+ return false;
1617
2908
  const obj = data;
1618
2909
  if (schema.required) {
1619
2910
  for (const r of schema.required) {
@@ -1643,9 +2934,12 @@ function validateJsonSchema(data, schema) {
1643
2934
  if (items && items.type && Array.isArray(value)) {
1644
2935
  const itemType = items.type;
1645
2936
  for (const item of value) {
1646
- if (itemType === "string" && typeof item !== "string") return false;
1647
- if (itemType === "number" && typeof item !== "number") return false;
1648
- 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;
1649
2943
  }
1650
2944
  }
1651
2945
  break;
@@ -1672,24 +2966,36 @@ var LLMManager = class {
1672
2966
  */
1673
2967
  async callLLM(systemPrompt, userPrompt, schema, options = {}) {
1674
2968
  const onUnhandled = (reason) => {
1675
- this.warnings.push(`Unhandled rejection during LLM call: ${String(reason)}`);
2969
+ this.warnings.push(
2970
+ `Unhandled rejection during LLM call: ${String(reason)}`
2971
+ );
1676
2972
  };
1677
2973
  process.on("unhandledRejection", onUnhandled);
1678
2974
  let clientPromise = void 0;
1679
2975
  try {
1680
- const estimatedTokens = this.estimateTokens(systemPrompt, userPrompt, options.requestedTokens);
2976
+ const estimatedTokens = this.estimateTokens(
2977
+ systemPrompt,
2978
+ userPrompt,
2979
+ options.requestedTokens
2980
+ );
1681
2981
  try {
1682
2982
  this.budgetManager.assertCanCall(estimatedTokens);
1683
2983
  } catch (e) {
1684
2984
  const msg = `LLM budget exhausted: ${e.message}`;
1685
2985
  this.warnings.push(msg);
1686
- globalThis.setTimeout(() => process.removeListener("unhandledRejection", onUnhandled), 100);
2986
+ globalThis.setTimeout(
2987
+ () => process.removeListener("unhandledRejection", onUnhandled),
2988
+ 100
2989
+ );
1687
2990
  return { success: false, fallback: true, error: msg };
1688
2991
  }
1689
2992
  if (!this.isValidJsonSchema(schema)) {
1690
2993
  const msg = "Invalid JSON schema provided";
1691
2994
  this.warnings.push(msg);
1692
- globalThis.setTimeout(() => process.removeListener("unhandledRejection", onUnhandled), 100);
2995
+ globalThis.setTimeout(
2996
+ () => process.removeListener("unhandledRejection", onUnhandled),
2997
+ 100
2998
+ );
1693
2999
  return { success: false, fallback: true, error: msg };
1694
3000
  }
1695
3001
  const strictUserPrompt = `${userPrompt}
@@ -1708,23 +3014,36 @@ No explanations. No markdown. No additional text.`;
1708
3014
  void clientPromise.catch(() => {
1709
3015
  });
1710
3016
  const timeoutPromise = new Promise((_, reject) => {
1711
- const id = globalThis.setTimeout(() => reject(new Error(`LLM call timeout after ${this.timeoutMs}ms`)), this.timeoutMs);
3017
+ const id = globalThis.setTimeout(
3018
+ () => reject(new Error(`LLM call timeout after ${this.timeoutMs}ms`)),
3019
+ this.timeoutMs
3020
+ );
1712
3021
  void clientPromise.finally(() => globalThis.clearTimeout(id)).catch(() => {
1713
3022
  });
1714
3023
  });
1715
3024
  void timeoutPromise.catch(() => {
1716
3025
  });
1717
- const response = await Promise.race([clientPromise, timeoutPromise]);
3026
+ const response = await Promise.race([
3027
+ clientPromise,
3028
+ timeoutPromise
3029
+ ]);
1718
3030
  if (!response || !response.content) {
1719
3031
  const graceMs = Math.min(Math.max(this.timeoutMs * 2, 100), 500);
1720
3032
  try {
1721
- await Promise.race([clientPromise.catch(() => {
1722
- }), new Promise((r) => setTimeout(r, graceMs))]);
1723
- } catch (e) {
3033
+ await Promise.race([
3034
+ clientPromise.catch(() => {
3035
+ }),
3036
+ new Promise((r) => setTimeout(r, graceMs))
3037
+ ]);
3038
+ } catch {
1724
3039
  } finally {
1725
3040
  process.removeListener("unhandledRejection", onUnhandled);
1726
3041
  }
1727
- 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
+ };
1728
3047
  }
1729
3048
  let parsedContent;
1730
3049
  try {
@@ -1738,9 +3057,12 @@ No explanations. No markdown. No additional text.`;
1738
3057
  this.warnings.push(msg);
1739
3058
  const graceMs = Math.min(Math.max(this.timeoutMs * 2, 100), 500);
1740
3059
  try {
1741
- await Promise.race([clientPromise.catch(() => {
1742
- }), new Promise((r) => setTimeout(r, graceMs))]);
1743
- } catch (e2) {
3060
+ await Promise.race([
3061
+ clientPromise.catch(() => {
3062
+ }),
3063
+ new Promise((r) => setTimeout(r, graceMs))
3064
+ ]);
3065
+ } catch {
1744
3066
  } finally {
1745
3067
  process.removeListener("unhandledRejection", onUnhandled);
1746
3068
  }
@@ -1751,9 +3073,12 @@ No explanations. No markdown. No additional text.`;
1751
3073
  this.warnings.push(msg);
1752
3074
  const graceMs = Math.min(Math.max(this.timeoutMs * 2, 100), 500);
1753
3075
  try {
1754
- await Promise.race([clientPromise.catch(() => {
1755
- }), new Promise((r) => setTimeout(r, graceMs))]);
1756
- } catch (e) {
3076
+ await Promise.race([
3077
+ clientPromise.catch(() => {
3078
+ }),
3079
+ new Promise((r) => setTimeout(r, graceMs))
3080
+ ]);
3081
+ } catch {
1757
3082
  } finally {
1758
3083
  process.removeListener("unhandledRejection", onUnhandled);
1759
3084
  }
@@ -1773,9 +3098,12 @@ No explanations. No markdown. No additional text.`;
1773
3098
  this.warnings.push(msg);
1774
3099
  const graceMs = Math.min(Math.max(this.timeoutMs * 2, 100), 500);
1775
3100
  try {
1776
- await Promise.race([clientPromise.catch(() => {
1777
- }), new Promise((r) => setTimeout(r, graceMs))]);
1778
- } catch (e2) {
3101
+ await Promise.race([
3102
+ clientPromise.catch(() => {
3103
+ }),
3104
+ new Promise((r) => setTimeout(r, graceMs))
3105
+ ]);
3106
+ } catch {
1779
3107
  } finally {
1780
3108
  process.removeListener("unhandledRejection", onUnhandled);
1781
3109
  }
@@ -1825,7 +3153,7 @@ No explanations. No markdown. No additional text.`;
1825
3153
  validateAgainstSchema(data, schema) {
1826
3154
  try {
1827
3155
  return validateJsonSchema(data, schema);
1828
- } catch (e) {
3156
+ } catch {
1829
3157
  if (typeof data !== "object" || data === null) {
1830
3158
  return false;
1831
3159
  }
@@ -1885,7 +3213,15 @@ var sectionClassificationSchema = {
1885
3213
  },
1886
3214
  classification: {
1887
3215
  type: "string",
1888
- 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
+ ],
1889
3225
  description: "Classified section type"
1890
3226
  },
1891
3227
  confidence: {
@@ -2188,7 +3524,10 @@ function analyzeResume(input) {
2188
3524
  let suggestions = suggestionResult.suggestions;
2189
3525
  const llmWarnings = [];
2190
3526
  if (input.llm && suggestionResult.suggestions.length > 0) {
2191
- const llmResult = enhanceSuggestionsWithLLM(input.llm, suggestionResult.suggestions);
3527
+ const llmResult = enhanceSuggestionsWithLLM(
3528
+ input.llm,
3529
+ suggestionResult.suggestions
3530
+ );
2192
3531
  if (llmResult.success) {
2193
3532
  suggestions = llmResult.enhancedSuggestions || suggestions;
2194
3533
  }
@@ -2213,17 +3552,20 @@ function analyzeResume(input) {
2213
3552
  detectedSections: parsedResume.detectedSections,
2214
3553
  parsedExperienceYears: parsedResume.totalExperienceYears,
2215
3554
  experienceEntries: parsedResume.experience,
3555
+ parseabilityReport: scoring.parseabilityReport,
3556
+ employmentGaps: scoring.employmentGaps,
3557
+ seniorityMatch: scoring.seniorityMatch,
3558
+ perSkillExperience: scoring.perSkillExperience,
2216
3559
  suggestions,
2217
3560
  warnings: [...suggestionResult.warnings, ...llmWarnings]
2218
3561
  };
2219
3562
  }
2220
- function enhanceSuggestionsWithLLM(config, suggestions) {
3563
+ function enhanceSuggestionsWithLLM(config, _suggestions) {
2221
3564
  if (!config.enable?.suggestions) {
2222
3565
  return { success: false, warnings: [] };
2223
3566
  }
2224
3567
  const warnings = [];
2225
3568
  try {
2226
- const llmManager = new LLMManager(config);
2227
3569
  warnings.push(
2228
3570
  "LLM suggestion enhancement skipped - use async analyzeResumeAsync for LLM features"
2229
3571
  );
@@ -2285,6 +3627,10 @@ async function analyzeResumeAsync(input) {
2285
3627
  detectedSections: parsedResume.detectedSections,
2286
3628
  parsedExperienceYears: parsedResume.totalExperienceYears,
2287
3629
  experienceEntries: parsedResume.experience,
3630
+ parseabilityReport: scoring.parseabilityReport,
3631
+ employmentGaps: scoring.employmentGaps,
3632
+ seniorityMatch: scoring.seniorityMatch,
3633
+ perSkillExperience: scoring.perSkillExperience,
2288
3634
  suggestions,
2289
3635
  warnings: [...suggestionResult.warnings, ...llmWarnings]
2290
3636
  };
@@ -2306,13 +3652,19 @@ async function enhanceSuggestionsWithLLMAsync(config, suggestions) {
2306
3652
  if (result.error) {
2307
3653
  warnings.push(`LLM suggestion enhancement failed: ${result.error}`);
2308
3654
  }
2309
- return { success: false, warnings: [...warnings, ...llmManager.getWarnings()] };
3655
+ return {
3656
+ success: false,
3657
+ warnings: [...warnings, ...llmManager.getWarnings()]
3658
+ };
2310
3659
  }
2311
3660
  const enhanced = adaptSuggestionEnhancementResponse(result.data);
2312
3661
  const enhancedSuggestions = enhanced.filter((e) => e.actionable !== false).map((e) => e.enhanced);
2313
3662
  if (enhancedSuggestions.length === 0) {
2314
3663
  warnings.push("LLM returned no actionable enhanced suggestions");
2315
- return { success: false, warnings: [...warnings, ...llmManager.getWarnings()] };
3664
+ return {
3665
+ success: false,
3666
+ warnings: [...warnings, ...llmManager.getWarnings()]
3667
+ };
2316
3668
  }
2317
3669
  return {
2318
3670
  success: true,
@@ -2320,7 +3672,9 @@ async function enhanceSuggestionsWithLLMAsync(config, suggestions) {
2320
3672
  warnings: llmManager.getWarnings()
2321
3673
  };
2322
3674
  } catch (e) {
2323
- warnings.push(`Unexpected error in LLM enhancement: ${e.message}`);
3675
+ warnings.push(
3676
+ `Unexpected error in LLM enhancement: ${e.message}`
3677
+ );
2324
3678
  return { success: false, warnings };
2325
3679
  }
2326
3680
  }