@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/README.md +88 -54
- package/dist/chunk-JAFFS7YZ.mjs +1219 -0
- package/dist/chunk-JAFFS7YZ.mjs.map +1 -0
- package/dist/{scoring-pZZR3soa.d.mts → config-CVmXtmvk.d.mts} +186 -90
- package/dist/{scoring-pZZR3soa.d.ts → config-CVmXtmvk.d.ts} +186 -90
- package/dist/index.cjs +1663 -309
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.mjs +759 -144
- package/dist/index.mjs.map +1 -1
- package/dist/lang/de/index.cjs +424 -29
- package/dist/lang/de/index.cjs.map +1 -1
- package/dist/lang/de/index.d.mts +1 -1
- package/dist/lang/de/index.d.ts +1 -1
- package/dist/lang/de/index.mjs +424 -29
- package/dist/lang/de/index.mjs.map +1 -1
- package/dist/lang/en/index.cjs +613 -41
- package/dist/lang/en/index.cjs.map +1 -1
- package/dist/lang/en/index.d.mts +1 -1
- package/dist/lang/en/index.d.ts +1 -1
- package/dist/lang/en/index.mjs +1 -1
- package/dist/lang/en/index.mjs.map +1 -1
- package/dist/pdf/index.cjs.map +1 -1
- package/dist/pdf/index.mjs.map +1 -1
- package/package.json +11 -2
- package/dist/chunk-IY7OVF6Z.mjs +0 -479
- package/dist/chunk-IY7OVF6Z.mjs.map +0 -1
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
261
|
-
|
|
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/
|
|
266
|
-
var
|
|
267
|
-
"
|
|
268
|
-
"
|
|
269
|
-
"
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
|
593
|
+
return best;
|
|
594
|
+
}
|
|
595
|
+
function normalizeTitle(title) {
|
|
596
|
+
return title.toLowerCase().replace(/[.,/#!$%^&*;:{}=_`~()]/g, " ").replace(/\s+/g, " ").trim();
|
|
379
597
|
}
|
|
380
|
-
function
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
|
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(
|
|
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))
|
|
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(
|
|
475
|
-
|
|
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 ?? [])
|
|
498
|
-
|
|
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("/"))
|
|
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
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
|
|
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([
|
|
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(
|
|
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(
|
|
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(
|
|
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: [
|
|
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: [
|
|
696
|
-
|
|
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: [
|
|
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 = [
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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(
|
|
731
|
-
|
|
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 {
|
|
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
|
|
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}(
|
|
746
|
-
|
|
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
|
|
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
|
-
|
|
783
|
-
|
|
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
|
|
804
|
-
const title =
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
850
|
-
|
|
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
|
|
1330
|
+
const skills = parseSkills(sections, config.skillAliases);
|
|
856
1331
|
const actionVerbs = parseActionVerbs(normalizedText);
|
|
857
|
-
const experienceData = parseExperience(
|
|
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 = [
|
|
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 = [
|
|
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 (
|
|
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(
|
|
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.
|
|
994
|
-
var EXPERIENCE_ROLE_WEIGHT = 0.
|
|
995
|
-
var
|
|
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(
|
|
1523
|
+
normalizeSkills(
|
|
1524
|
+
[...job.requiredSkills, ...profileRequired],
|
|
1525
|
+
config.skillAliases,
|
|
1526
|
+
matchOptions
|
|
1527
|
+
)
|
|
1008
1528
|
);
|
|
1009
1529
|
const optional = new Set(
|
|
1010
|
-
normalizeSkills(
|
|
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
|
|
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
|
|
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 {
|
|
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(
|
|
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(
|
|
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(
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
const
|
|
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
|
|
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(
|
|
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)
|
|
1118
|
-
|
|
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(
|
|
1128
|
-
|
|
1129
|
-
(requirement) => resumeDegreeLevels.includes(requirement)
|
|
1708
|
+
const resumeDegreeLevels = extractDegreeLevels(
|
|
1709
|
+
resume.educationEntries.join(" ")
|
|
1130
1710
|
);
|
|
1131
|
-
if (
|
|
1711
|
+
if (resumeDegreeLevels.length === 0) {
|
|
1132
1712
|
return 0;
|
|
1133
1713
|
}
|
|
1134
|
-
|
|
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
|
|
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
|
-
|
|
1154
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
1191
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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: "
|
|
1221
|
-
{ canonical: "
|
|
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: "
|
|
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
|
-
{
|
|
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: "
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
1260
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
1267
|
-
|
|
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: "
|
|
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
|
-
{
|
|
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
|
-
{
|
|
1303
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
1354
|
-
|
|
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
|
-
{
|
|
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: "
|
|
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
|
-
{
|
|
1369
|
-
|
|
1370
|
-
|
|
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.
|
|
1400
|
-
experience: 0.
|
|
2640
|
+
skills: 0.25,
|
|
2641
|
+
experience: 0.2,
|
|
1401
2642
|
keywords: 0.25,
|
|
1402
|
-
|
|
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
|
-
//
|
|
1415
|
-
//
|
|
1416
|
-
missingContact:
|
|
2656
|
+
// Raised from 0 (warning-only) in v1 — a real ATS treats an unparseable contact email as a
|
|
2657
|
+
// near-knockout (no way to reach the candidate at all), so this now actually moves the score.
|
|
2658
|
+
missingContact: 12
|
|
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 /
|
|
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(
|
|
2690
|
+
const keywordRegistry = mergeKeywordRegistries(
|
|
2691
|
+
defaultKeywordRegistry,
|
|
2692
|
+
config.keywordRegistry ?? []
|
|
2693
|
+
);
|
|
1446
2694
|
const resolved = {
|
|
1447
2695
|
weights: normalizeWeights(weights),
|
|
1448
|
-
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(
|
|
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(
|
|
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 = [
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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)
|
|
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")
|
|
1647
|
-
|
|
1648
|
-
if (itemType === "
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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([
|
|
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([
|
|
1722
|
-
|
|
1723
|
-
|
|
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 {
|
|
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([
|
|
1742
|
-
|
|
1743
|
-
|
|
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([
|
|
1755
|
-
|
|
1756
|
-
|
|
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([
|
|
1777
|
-
|
|
1778
|
-
|
|
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
|
|
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: [
|
|
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(
|
|
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,
|
|
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 {
|
|
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 {
|
|
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(
|
|
3675
|
+
warnings.push(
|
|
3676
|
+
`Unexpected error in LLM enhancement: ${e.message}`
|
|
3677
|
+
);
|
|
2324
3678
|
return { success: false, warnings };
|
|
2325
3679
|
}
|
|
2326
3680
|
}
|