resuml 3.0.0 → 3.2.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/DOCS.md +77 -18
- package/README.md +5 -0
- package/bin/resuml +3 -2
- package/data/jobs/companies.json +76 -0
- package/dist/ats/index.d.ts +7 -0
- package/dist/ats/index.js +8 -0
- package/dist/ats/index.js.map +1 -0
- package/dist/chunk-C2JG5KF4.js +2088 -0
- package/dist/chunk-C2JG5KF4.js.map +1 -0
- package/dist/chunk-G4AN2EMI.js +461 -0
- package/dist/chunk-G4AN2EMI.js.map +1 -0
- package/dist/chunk-QBCXFLW6.js +686 -0
- package/dist/chunk-QBCXFLW6.js.map +1 -0
- package/dist/{chunk-R4MD5YMV.js → chunk-QR77BRMN.js} +154 -2434
- package/dist/chunk-QR77BRMN.js.map +1 -0
- package/dist/chunk-YVC53STN.js +997 -0
- package/dist/chunk-YVC53STN.js.map +1 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +25 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +6 -424
- package/dist/index.js +20 -775
- package/dist/index.js.map +1 -1
- package/dist/jobs/index.d.ts +158 -0
- package/dist/jobs/index.js +23 -0
- package/dist/jobs/index.js.map +1 -0
- package/dist/mcp/server.js +134 -3
- package/dist/mcp/server.js.map +1 -1
- package/dist/skills/index.d.ts +67 -0
- package/dist/skills/index.js +13 -0
- package/dist/skills/index.js.map +1 -0
- package/dist/types/index.d.ts +343 -0
- package/dist/types/index.js +1 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types-B_jASYU4.d.ts +81 -0
- package/package.json +33 -3
- package/dist/chunk-R4MD5YMV.js.map +0 -1
- package/src/types/resume.ts +0 -344
- package/src/types/schema.d.ts +0 -6
|
@@ -0,0 +1,2088 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getSkillIndex
|
|
3
|
+
} from "./chunk-QR77BRMN.js";
|
|
4
|
+
|
|
5
|
+
// src/ats/checks/parsing.ts
|
|
6
|
+
var ISO_DATE = /^\d{4}(-\d{2})?(-\d{2})?$/;
|
|
7
|
+
var conventionalSections = (resume) => {
|
|
8
|
+
const required = ["basics", "work", "education"];
|
|
9
|
+
const missing = required.filter((s) => {
|
|
10
|
+
const v = resume[s];
|
|
11
|
+
if (Array.isArray(v)) return v.length === 0;
|
|
12
|
+
return v === void 0;
|
|
13
|
+
});
|
|
14
|
+
const passed = missing.length === 0;
|
|
15
|
+
return {
|
|
16
|
+
id: "conventional-sections",
|
|
17
|
+
tier: "parsing",
|
|
18
|
+
weight: "high",
|
|
19
|
+
status: passed ? "pass" : "fail",
|
|
20
|
+
score: Math.round((required.length - missing.length) / required.length * 100),
|
|
21
|
+
message: passed ? "All conventional sections present." : `Missing required sections: ${missing.join(", ")}.`,
|
|
22
|
+
hints: passed ? [] : [`Add ${missing.join(", ")} to your YAML.`]
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
var dateFormatConsistency = (resume) => {
|
|
26
|
+
const all = [];
|
|
27
|
+
for (const w of resume.work || []) {
|
|
28
|
+
if (w.startDate) all.push({ date: w.startDate, where: `work "${w.name || ""}".startDate` });
|
|
29
|
+
if (w.endDate) all.push({ date: w.endDate, where: `work "${w.name || ""}".endDate` });
|
|
30
|
+
}
|
|
31
|
+
for (const e of resume.education || []) {
|
|
32
|
+
if (e.startDate)
|
|
33
|
+
all.push({ date: e.startDate, where: `education "${e.institution || ""}".startDate` });
|
|
34
|
+
if (e.endDate)
|
|
35
|
+
all.push({ date: e.endDate, where: `education "${e.institution || ""}".endDate` });
|
|
36
|
+
}
|
|
37
|
+
if (all.length === 0) {
|
|
38
|
+
return {
|
|
39
|
+
id: "date-format-consistency",
|
|
40
|
+
tier: "parsing",
|
|
41
|
+
weight: "medium",
|
|
42
|
+
status: "skipped",
|
|
43
|
+
score: 0,
|
|
44
|
+
message: "No dates to check.",
|
|
45
|
+
hints: []
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const bad = all.filter((d) => !ISO_DATE.test(d.date));
|
|
49
|
+
const passed = bad.length === 0;
|
|
50
|
+
return {
|
|
51
|
+
id: "date-format-consistency",
|
|
52
|
+
tier: "parsing",
|
|
53
|
+
weight: "medium",
|
|
54
|
+
status: passed ? "pass" : bad.length <= 1 ? "warn" : "fail",
|
|
55
|
+
score: Math.round((all.length - bad.length) / all.length * 100),
|
|
56
|
+
message: passed ? "All dates use ISO-8601 format." : `Non-ISO dates: ${bad.slice(0, 3).map((b) => `${b.where}=${b.date}`).join("; ")}.`,
|
|
57
|
+
hints: passed ? [] : ["Use YYYY-MM or YYYY-MM-DD for every date field."]
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
var contactInBody = (resume) => {
|
|
61
|
+
const b = resume.basics;
|
|
62
|
+
const checks = [
|
|
63
|
+
{ ok: !!b?.name, field: "name" },
|
|
64
|
+
{ ok: !!b?.email, field: "email" },
|
|
65
|
+
{ ok: !!b?.phone, field: "phone" },
|
|
66
|
+
{ ok: !!b?.location?.city, field: "location.city" }
|
|
67
|
+
];
|
|
68
|
+
const missing = checks.filter((c) => !c.ok).map((c) => c.field);
|
|
69
|
+
const passed = missing.length === 0;
|
|
70
|
+
return {
|
|
71
|
+
id: "contact-in-body",
|
|
72
|
+
tier: "parsing",
|
|
73
|
+
weight: "high",
|
|
74
|
+
status: passed ? "pass" : "fail",
|
|
75
|
+
score: Math.round((checks.length - missing.length) / checks.length * 100),
|
|
76
|
+
message: passed ? "Contact information present in basics." : `Missing contact fields: ${missing.join(", ")}.`,
|
|
77
|
+
hints: passed ? [] : [`Add ${missing.join(", ")} to basics.`]
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
var reverseChronOrder = (resume) => {
|
|
81
|
+
const work = resume.work || [];
|
|
82
|
+
if (work.length < 2) {
|
|
83
|
+
return {
|
|
84
|
+
id: "reverse-chron-order",
|
|
85
|
+
tier: "parsing",
|
|
86
|
+
weight: "medium",
|
|
87
|
+
status: "skipped",
|
|
88
|
+
score: 100,
|
|
89
|
+
message: "Single or no work entry.",
|
|
90
|
+
hints: []
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
let outOfOrder = 0;
|
|
94
|
+
for (let i = 0; i < work.length - 1; i++) {
|
|
95
|
+
const curr = work[i];
|
|
96
|
+
const next = work[i + 1];
|
|
97
|
+
const a = curr?.startDate || "";
|
|
98
|
+
const b = next?.startDate || "";
|
|
99
|
+
if (a && b && a < b) outOfOrder++;
|
|
100
|
+
}
|
|
101
|
+
const passed = outOfOrder === 0;
|
|
102
|
+
return {
|
|
103
|
+
id: "reverse-chron-order",
|
|
104
|
+
tier: "parsing",
|
|
105
|
+
weight: "medium",
|
|
106
|
+
status: passed ? "pass" : "fail",
|
|
107
|
+
score: passed ? 100 : Math.max(0, 100 - outOfOrder * 50),
|
|
108
|
+
message: passed ? "Work entries in reverse-chronological order." : `${outOfOrder} pair(s) out of reverse-chronological order.`,
|
|
109
|
+
hints: passed ? [] : ["Reorder work[] so the most recent role is first."]
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
var educationComplete = (resume) => {
|
|
113
|
+
const edu = resume.education || [];
|
|
114
|
+
if (edu.length === 0) {
|
|
115
|
+
return {
|
|
116
|
+
id: "education-complete",
|
|
117
|
+
tier: "parsing",
|
|
118
|
+
weight: "low",
|
|
119
|
+
status: "fail",
|
|
120
|
+
score: 0,
|
|
121
|
+
message: "No education entries.",
|
|
122
|
+
hints: ["Add at least one education entry with institution, area, and studyType."]
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const incomplete = edu.filter((e) => !e.institution || !e.area || !e.studyType);
|
|
126
|
+
const passed = incomplete.length === 0;
|
|
127
|
+
return {
|
|
128
|
+
id: "education-complete",
|
|
129
|
+
tier: "parsing",
|
|
130
|
+
weight: "low",
|
|
131
|
+
status: passed ? "pass" : "fail",
|
|
132
|
+
score: Math.round((edu.length - incomplete.length) / edu.length * 100),
|
|
133
|
+
message: passed ? "All education entries complete." : `${incomplete.length} education entry(ies) missing institution/area/studyType.`,
|
|
134
|
+
hints: passed ? [] : ["Fill in institution, area and studyType for every education entry."]
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
var allParsingChecks = [
|
|
138
|
+
conventionalSections,
|
|
139
|
+
dateFormatConsistency,
|
|
140
|
+
contactInBody,
|
|
141
|
+
reverseChronOrder,
|
|
142
|
+
educationComplete
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
// src/ats/i18n/en.ts
|
|
146
|
+
var en = {
|
|
147
|
+
actionVerbs: [
|
|
148
|
+
// Leadership & Management
|
|
149
|
+
"achieved",
|
|
150
|
+
"administered",
|
|
151
|
+
"advanced",
|
|
152
|
+
"allocated",
|
|
153
|
+
"approved",
|
|
154
|
+
"assigned",
|
|
155
|
+
"authorized",
|
|
156
|
+
"chaired",
|
|
157
|
+
"consolidated",
|
|
158
|
+
"coordinated",
|
|
159
|
+
"delegated",
|
|
160
|
+
"directed",
|
|
161
|
+
"established",
|
|
162
|
+
"executed",
|
|
163
|
+
"headed",
|
|
164
|
+
"hired",
|
|
165
|
+
"hosted",
|
|
166
|
+
"led",
|
|
167
|
+
"managed",
|
|
168
|
+
"mentored",
|
|
169
|
+
"motivated",
|
|
170
|
+
"orchestrated",
|
|
171
|
+
"organized",
|
|
172
|
+
"oversaw",
|
|
173
|
+
"planned",
|
|
174
|
+
"presided",
|
|
175
|
+
"prioritized",
|
|
176
|
+
"produced",
|
|
177
|
+
"recruited",
|
|
178
|
+
"spearheaded",
|
|
179
|
+
"supervised",
|
|
180
|
+
// Technical & Engineering
|
|
181
|
+
"architected",
|
|
182
|
+
"automated",
|
|
183
|
+
"built",
|
|
184
|
+
"coded",
|
|
185
|
+
"configured",
|
|
186
|
+
"debugged",
|
|
187
|
+
"deployed",
|
|
188
|
+
"designed",
|
|
189
|
+
"developed",
|
|
190
|
+
"devised",
|
|
191
|
+
"engineered",
|
|
192
|
+
"implemented",
|
|
193
|
+
"installed",
|
|
194
|
+
"integrated",
|
|
195
|
+
"launched",
|
|
196
|
+
"maintained",
|
|
197
|
+
"migrated",
|
|
198
|
+
"modernized",
|
|
199
|
+
"optimized",
|
|
200
|
+
"overhauled",
|
|
201
|
+
"programmed",
|
|
202
|
+
"prototyped",
|
|
203
|
+
"refactored",
|
|
204
|
+
"reengineered",
|
|
205
|
+
"resolved",
|
|
206
|
+
"restructured",
|
|
207
|
+
"revamped",
|
|
208
|
+
"scaled",
|
|
209
|
+
"shipped",
|
|
210
|
+
"standardized",
|
|
211
|
+
"streamlined",
|
|
212
|
+
"tested",
|
|
213
|
+
"troubleshot",
|
|
214
|
+
"upgraded",
|
|
215
|
+
// Achievement & Impact
|
|
216
|
+
"accelerated",
|
|
217
|
+
"accomplished",
|
|
218
|
+
"boosted",
|
|
219
|
+
"completed",
|
|
220
|
+
"contributed",
|
|
221
|
+
"converted",
|
|
222
|
+
"decreased",
|
|
223
|
+
"delivered",
|
|
224
|
+
"doubled",
|
|
225
|
+
"earned",
|
|
226
|
+
"eliminated",
|
|
227
|
+
"exceeded",
|
|
228
|
+
"expanded",
|
|
229
|
+
"expedited",
|
|
230
|
+
"generated",
|
|
231
|
+
"grew",
|
|
232
|
+
"improved",
|
|
233
|
+
"increased",
|
|
234
|
+
"maximized",
|
|
235
|
+
"minimized",
|
|
236
|
+
"outperformed",
|
|
237
|
+
"pioneered",
|
|
238
|
+
"recovered",
|
|
239
|
+
"reduced",
|
|
240
|
+
"saved",
|
|
241
|
+
"simplified",
|
|
242
|
+
"solved",
|
|
243
|
+
"surpassed",
|
|
244
|
+
"transformed",
|
|
245
|
+
"tripled",
|
|
246
|
+
// Communication & Collaboration
|
|
247
|
+
"advised",
|
|
248
|
+
"advocated",
|
|
249
|
+
"briefed",
|
|
250
|
+
"collaborated",
|
|
251
|
+
"communicated",
|
|
252
|
+
"consulted",
|
|
253
|
+
"convinced",
|
|
254
|
+
"counseled",
|
|
255
|
+
"defined",
|
|
256
|
+
"demonstrated",
|
|
257
|
+
"documented",
|
|
258
|
+
"educated",
|
|
259
|
+
"facilitated",
|
|
260
|
+
"guided",
|
|
261
|
+
"influenced",
|
|
262
|
+
"informed",
|
|
263
|
+
"instructed",
|
|
264
|
+
"liaised",
|
|
265
|
+
"negotiated",
|
|
266
|
+
"partnered",
|
|
267
|
+
"persuaded",
|
|
268
|
+
"presented",
|
|
269
|
+
"promoted",
|
|
270
|
+
"proposed",
|
|
271
|
+
"published",
|
|
272
|
+
"recommended",
|
|
273
|
+
"represented",
|
|
274
|
+
"trained",
|
|
275
|
+
// Analysis & Research
|
|
276
|
+
"analyzed",
|
|
277
|
+
"assessed",
|
|
278
|
+
"audited",
|
|
279
|
+
"benchmarked",
|
|
280
|
+
"calculated",
|
|
281
|
+
"compared",
|
|
282
|
+
"compiled",
|
|
283
|
+
"conducted",
|
|
284
|
+
"discovered",
|
|
285
|
+
"evaluated",
|
|
286
|
+
"examined",
|
|
287
|
+
"explored",
|
|
288
|
+
"forecasted",
|
|
289
|
+
"identified",
|
|
290
|
+
"inspected",
|
|
291
|
+
"interpreted",
|
|
292
|
+
"investigated",
|
|
293
|
+
"mapped",
|
|
294
|
+
"measured",
|
|
295
|
+
"modeled",
|
|
296
|
+
"monitored",
|
|
297
|
+
"quantified",
|
|
298
|
+
"researched",
|
|
299
|
+
"reviewed",
|
|
300
|
+
"surveyed",
|
|
301
|
+
"synthesized",
|
|
302
|
+
"tracked",
|
|
303
|
+
"validated",
|
|
304
|
+
"verified",
|
|
305
|
+
// Creation & Innovation
|
|
306
|
+
"conceptualized",
|
|
307
|
+
"crafted",
|
|
308
|
+
"created",
|
|
309
|
+
"customized",
|
|
310
|
+
"formulated",
|
|
311
|
+
"founded",
|
|
312
|
+
"initiated",
|
|
313
|
+
"innovated",
|
|
314
|
+
"introduced",
|
|
315
|
+
"invented",
|
|
316
|
+
"originated",
|
|
317
|
+
"shaped"
|
|
318
|
+
],
|
|
319
|
+
pronouns: ["i", "me", "my", "mine", "myself", "we", "our", "ours"],
|
|
320
|
+
stopWords: [
|
|
321
|
+
// Articles & determiners
|
|
322
|
+
"a",
|
|
323
|
+
"an",
|
|
324
|
+
"the",
|
|
325
|
+
"and",
|
|
326
|
+
"or",
|
|
327
|
+
"but",
|
|
328
|
+
"in",
|
|
329
|
+
"on",
|
|
330
|
+
"at",
|
|
331
|
+
"to",
|
|
332
|
+
"for",
|
|
333
|
+
"of",
|
|
334
|
+
"with",
|
|
335
|
+
"by",
|
|
336
|
+
"from",
|
|
337
|
+
"is",
|
|
338
|
+
"was",
|
|
339
|
+
"are",
|
|
340
|
+
"were",
|
|
341
|
+
"be",
|
|
342
|
+
"been",
|
|
343
|
+
"being",
|
|
344
|
+
"have",
|
|
345
|
+
"has",
|
|
346
|
+
"had",
|
|
347
|
+
"do",
|
|
348
|
+
"does",
|
|
349
|
+
"did",
|
|
350
|
+
"will",
|
|
351
|
+
"would",
|
|
352
|
+
"could",
|
|
353
|
+
"should",
|
|
354
|
+
"may",
|
|
355
|
+
"might",
|
|
356
|
+
"shall",
|
|
357
|
+
"can",
|
|
358
|
+
"this",
|
|
359
|
+
"that",
|
|
360
|
+
"these",
|
|
361
|
+
"those",
|
|
362
|
+
"it",
|
|
363
|
+
"its",
|
|
364
|
+
"as",
|
|
365
|
+
"if",
|
|
366
|
+
"not",
|
|
367
|
+
"no",
|
|
368
|
+
"so",
|
|
369
|
+
"up",
|
|
370
|
+
"out",
|
|
371
|
+
"about",
|
|
372
|
+
"into",
|
|
373
|
+
"over",
|
|
374
|
+
"after",
|
|
375
|
+
"before",
|
|
376
|
+
"between",
|
|
377
|
+
"under",
|
|
378
|
+
"above",
|
|
379
|
+
"below",
|
|
380
|
+
"all",
|
|
381
|
+
"each",
|
|
382
|
+
"every",
|
|
383
|
+
"both",
|
|
384
|
+
"few",
|
|
385
|
+
"more",
|
|
386
|
+
"most",
|
|
387
|
+
"other",
|
|
388
|
+
"some",
|
|
389
|
+
"such",
|
|
390
|
+
"than",
|
|
391
|
+
"too",
|
|
392
|
+
"very",
|
|
393
|
+
// Pronouns & possessives (also checked by pronoun check, but filter from JD keywords)
|
|
394
|
+
"you",
|
|
395
|
+
"your",
|
|
396
|
+
"yours",
|
|
397
|
+
"yourself",
|
|
398
|
+
"we",
|
|
399
|
+
"our",
|
|
400
|
+
"ours",
|
|
401
|
+
"ourselves",
|
|
402
|
+
"they",
|
|
403
|
+
"them",
|
|
404
|
+
"their",
|
|
405
|
+
"theirs",
|
|
406
|
+
"he",
|
|
407
|
+
"she",
|
|
408
|
+
"his",
|
|
409
|
+
"her",
|
|
410
|
+
"hers",
|
|
411
|
+
"who",
|
|
412
|
+
"whom",
|
|
413
|
+
"whose",
|
|
414
|
+
"which",
|
|
415
|
+
"what",
|
|
416
|
+
"where",
|
|
417
|
+
"when",
|
|
418
|
+
"how",
|
|
419
|
+
"why",
|
|
420
|
+
// Common JD filler words (not meaningful for skill matching)
|
|
421
|
+
"able",
|
|
422
|
+
"also",
|
|
423
|
+
"across",
|
|
424
|
+
"already",
|
|
425
|
+
"always",
|
|
426
|
+
"among",
|
|
427
|
+
"any",
|
|
428
|
+
"apply",
|
|
429
|
+
"become",
|
|
430
|
+
"believe",
|
|
431
|
+
"best",
|
|
432
|
+
"bring",
|
|
433
|
+
"change",
|
|
434
|
+
"come",
|
|
435
|
+
"committed",
|
|
436
|
+
"company",
|
|
437
|
+
"comfortable",
|
|
438
|
+
"critical",
|
|
439
|
+
"current",
|
|
440
|
+
"day",
|
|
441
|
+
"desired",
|
|
442
|
+
"either",
|
|
443
|
+
"end",
|
|
444
|
+
"ensure",
|
|
445
|
+
"environment",
|
|
446
|
+
"equal",
|
|
447
|
+
"even",
|
|
448
|
+
"excellent",
|
|
449
|
+
"exciting",
|
|
450
|
+
"exceptional",
|
|
451
|
+
"expected",
|
|
452
|
+
"experience",
|
|
453
|
+
"fast",
|
|
454
|
+
"field",
|
|
455
|
+
"find",
|
|
456
|
+
"first",
|
|
457
|
+
"focused",
|
|
458
|
+
"follow",
|
|
459
|
+
"get",
|
|
460
|
+
"give",
|
|
461
|
+
"go",
|
|
462
|
+
"going",
|
|
463
|
+
"good",
|
|
464
|
+
"great",
|
|
465
|
+
"group",
|
|
466
|
+
"grow",
|
|
467
|
+
"growing",
|
|
468
|
+
"growth",
|
|
469
|
+
"help",
|
|
470
|
+
"here",
|
|
471
|
+
"high",
|
|
472
|
+
"highly",
|
|
473
|
+
"ideal",
|
|
474
|
+
"impact",
|
|
475
|
+
"important",
|
|
476
|
+
"include",
|
|
477
|
+
"includes",
|
|
478
|
+
"including",
|
|
479
|
+
"industry",
|
|
480
|
+
"interested",
|
|
481
|
+
"job",
|
|
482
|
+
"join",
|
|
483
|
+
"just",
|
|
484
|
+
"keep",
|
|
485
|
+
"key",
|
|
486
|
+
"know",
|
|
487
|
+
"large",
|
|
488
|
+
"latest",
|
|
489
|
+
"lead",
|
|
490
|
+
"level",
|
|
491
|
+
"like",
|
|
492
|
+
"location",
|
|
493
|
+
"long",
|
|
494
|
+
"look",
|
|
495
|
+
"looking",
|
|
496
|
+
"love",
|
|
497
|
+
"make",
|
|
498
|
+
"many",
|
|
499
|
+
"much",
|
|
500
|
+
"must",
|
|
501
|
+
"need",
|
|
502
|
+
"new",
|
|
503
|
+
"next",
|
|
504
|
+
"offer",
|
|
505
|
+
"one",
|
|
506
|
+
"only",
|
|
507
|
+
"open",
|
|
508
|
+
"opportunity",
|
|
509
|
+
"order",
|
|
510
|
+
"others",
|
|
511
|
+
"own",
|
|
512
|
+
"pace",
|
|
513
|
+
"part",
|
|
514
|
+
"partner",
|
|
515
|
+
"passionate",
|
|
516
|
+
"people",
|
|
517
|
+
"per",
|
|
518
|
+
"play",
|
|
519
|
+
"plus",
|
|
520
|
+
"position",
|
|
521
|
+
"preferred",
|
|
522
|
+
"provide",
|
|
523
|
+
"put",
|
|
524
|
+
"qualifications",
|
|
525
|
+
"quickly",
|
|
526
|
+
"range",
|
|
527
|
+
"related",
|
|
528
|
+
"required",
|
|
529
|
+
"requirements",
|
|
530
|
+
"requirement",
|
|
531
|
+
"responsible",
|
|
532
|
+
"responsibilities",
|
|
533
|
+
"responsibility",
|
|
534
|
+
"result",
|
|
535
|
+
"right",
|
|
536
|
+
"role",
|
|
537
|
+
"run",
|
|
538
|
+
"same",
|
|
539
|
+
"see",
|
|
540
|
+
"seek",
|
|
541
|
+
"seeking",
|
|
542
|
+
"set",
|
|
543
|
+
"several",
|
|
544
|
+
"since",
|
|
545
|
+
"skills",
|
|
546
|
+
"someone",
|
|
547
|
+
"start",
|
|
548
|
+
"state",
|
|
549
|
+
"still",
|
|
550
|
+
"strong",
|
|
551
|
+
"success",
|
|
552
|
+
"successful",
|
|
553
|
+
"support",
|
|
554
|
+
"sure",
|
|
555
|
+
"take",
|
|
556
|
+
"team",
|
|
557
|
+
"then",
|
|
558
|
+
"there",
|
|
559
|
+
"thing",
|
|
560
|
+
"think",
|
|
561
|
+
"through",
|
|
562
|
+
"time",
|
|
563
|
+
"together",
|
|
564
|
+
"top",
|
|
565
|
+
"truly",
|
|
566
|
+
"try",
|
|
567
|
+
"two",
|
|
568
|
+
"type",
|
|
569
|
+
"use",
|
|
570
|
+
"used",
|
|
571
|
+
"using",
|
|
572
|
+
"value",
|
|
573
|
+
"want",
|
|
574
|
+
"way",
|
|
575
|
+
"well",
|
|
576
|
+
"while",
|
|
577
|
+
"within",
|
|
578
|
+
"without",
|
|
579
|
+
"work",
|
|
580
|
+
"working",
|
|
581
|
+
"world",
|
|
582
|
+
"would",
|
|
583
|
+
"year",
|
|
584
|
+
"years",
|
|
585
|
+
// Section headers & structural words (not technical skills)
|
|
586
|
+
"description",
|
|
587
|
+
"overview",
|
|
588
|
+
"summary",
|
|
589
|
+
"duties",
|
|
590
|
+
"bachelor",
|
|
591
|
+
"bachelors",
|
|
592
|
+
"master",
|
|
593
|
+
"masters",
|
|
594
|
+
"degree",
|
|
595
|
+
"phd",
|
|
596
|
+
"minimum",
|
|
597
|
+
"preferred",
|
|
598
|
+
"implement",
|
|
599
|
+
"process",
|
|
600
|
+
"robust",
|
|
601
|
+
"consistent",
|
|
602
|
+
"operations",
|
|
603
|
+
// URL/email/domain fragments
|
|
604
|
+
"http",
|
|
605
|
+
"https",
|
|
606
|
+
"www",
|
|
607
|
+
"com",
|
|
608
|
+
"org",
|
|
609
|
+
"net",
|
|
610
|
+
"mailto",
|
|
611
|
+
// Resume/YAML schema field names (in case raw YAML is pasted)
|
|
612
|
+
"name",
|
|
613
|
+
"keywords",
|
|
614
|
+
"highlights",
|
|
615
|
+
"startdate",
|
|
616
|
+
"enddate",
|
|
617
|
+
"website",
|
|
618
|
+
"profiles",
|
|
619
|
+
"basics",
|
|
620
|
+
"position",
|
|
621
|
+
"institution",
|
|
622
|
+
"studytype",
|
|
623
|
+
"fluency",
|
|
624
|
+
"issuer",
|
|
625
|
+
"network",
|
|
626
|
+
"username",
|
|
627
|
+
"countrycode",
|
|
628
|
+
"region",
|
|
629
|
+
// Generic nouns that aren't skills
|
|
630
|
+
"product",
|
|
631
|
+
"company",
|
|
632
|
+
"service",
|
|
633
|
+
"services",
|
|
634
|
+
"platform",
|
|
635
|
+
"solutions",
|
|
636
|
+
"ability",
|
|
637
|
+
"opportunity",
|
|
638
|
+
"candidate",
|
|
639
|
+
"applicant",
|
|
640
|
+
"position",
|
|
641
|
+
"salary",
|
|
642
|
+
"compensation",
|
|
643
|
+
"benefits",
|
|
644
|
+
"perks",
|
|
645
|
+
"bonus",
|
|
646
|
+
"development",
|
|
647
|
+
"management",
|
|
648
|
+
"knowledge",
|
|
649
|
+
"modern",
|
|
650
|
+
"advanced",
|
|
651
|
+
"practices",
|
|
652
|
+
"nice",
|
|
653
|
+
"technologies",
|
|
654
|
+
"technology",
|
|
655
|
+
"frameworks",
|
|
656
|
+
"framework",
|
|
657
|
+
"tools",
|
|
658
|
+
"data",
|
|
659
|
+
"based",
|
|
660
|
+
"contribute",
|
|
661
|
+
"contributions",
|
|
662
|
+
"migration",
|
|
663
|
+
"leading",
|
|
664
|
+
"source",
|
|
665
|
+
"visit",
|
|
666
|
+
// Common verbs & verb forms (not technical skills, supplement action verbs list)
|
|
667
|
+
"collaborate",
|
|
668
|
+
"collaborating",
|
|
669
|
+
"collaboratively",
|
|
670
|
+
"communicate",
|
|
671
|
+
"communicating",
|
|
672
|
+
"contributing",
|
|
673
|
+
"coordinate",
|
|
674
|
+
"coordinating",
|
|
675
|
+
"demonstrate",
|
|
676
|
+
"demonstrating",
|
|
677
|
+
"design",
|
|
678
|
+
"designing",
|
|
679
|
+
"designed",
|
|
680
|
+
"develop",
|
|
681
|
+
"developing",
|
|
682
|
+
"developed",
|
|
683
|
+
"drive",
|
|
684
|
+
"driving",
|
|
685
|
+
"driven",
|
|
686
|
+
"enable",
|
|
687
|
+
"enabling",
|
|
688
|
+
"evaluate",
|
|
689
|
+
"evaluating",
|
|
690
|
+
"execute",
|
|
691
|
+
"executing",
|
|
692
|
+
"facilitate",
|
|
693
|
+
"facilitating",
|
|
694
|
+
"identify",
|
|
695
|
+
"identifying",
|
|
696
|
+
"influence",
|
|
697
|
+
"influencing",
|
|
698
|
+
"interact",
|
|
699
|
+
"interacting",
|
|
700
|
+
"lead",
|
|
701
|
+
"leverage",
|
|
702
|
+
"leveraging",
|
|
703
|
+
"manage",
|
|
704
|
+
"managing",
|
|
705
|
+
"mentor",
|
|
706
|
+
"mentoring",
|
|
707
|
+
"operate",
|
|
708
|
+
"operating",
|
|
709
|
+
"optimize",
|
|
710
|
+
"optimizing",
|
|
711
|
+
"participate",
|
|
712
|
+
"participating",
|
|
713
|
+
"report",
|
|
714
|
+
"reporting",
|
|
715
|
+
"solve",
|
|
716
|
+
"solving",
|
|
717
|
+
"understand",
|
|
718
|
+
"understanding",
|
|
719
|
+
// Common adjectives & descriptors (not technical skills)
|
|
720
|
+
"fluent",
|
|
721
|
+
"proficient",
|
|
722
|
+
"deep",
|
|
723
|
+
"solid",
|
|
724
|
+
"proven",
|
|
725
|
+
"hands-on",
|
|
726
|
+
"detail-oriented",
|
|
727
|
+
"results-driven",
|
|
728
|
+
"self-motivated",
|
|
729
|
+
"proactive",
|
|
730
|
+
"creative",
|
|
731
|
+
"innovative",
|
|
732
|
+
"dynamic",
|
|
733
|
+
"strategic",
|
|
734
|
+
"analytical",
|
|
735
|
+
"collaborative",
|
|
736
|
+
"effective",
|
|
737
|
+
"efficient",
|
|
738
|
+
"reliable",
|
|
739
|
+
"flexible",
|
|
740
|
+
"adaptable",
|
|
741
|
+
"motivated",
|
|
742
|
+
"dedicated",
|
|
743
|
+
"capable",
|
|
744
|
+
"qualified",
|
|
745
|
+
"diverse",
|
|
746
|
+
"inclusive",
|
|
747
|
+
"global",
|
|
748
|
+
"local",
|
|
749
|
+
"remote",
|
|
750
|
+
"hybrid",
|
|
751
|
+
"onsite",
|
|
752
|
+
"full-time",
|
|
753
|
+
"part-time",
|
|
754
|
+
"contract",
|
|
755
|
+
"permanent",
|
|
756
|
+
// Role titles & department names (not skills themselves)
|
|
757
|
+
"designer",
|
|
758
|
+
"designers",
|
|
759
|
+
"developer",
|
|
760
|
+
"developers",
|
|
761
|
+
"engineer",
|
|
762
|
+
"engineers",
|
|
763
|
+
"manager",
|
|
764
|
+
"managers",
|
|
765
|
+
"director",
|
|
766
|
+
"analyst",
|
|
767
|
+
"analysts",
|
|
768
|
+
"architect",
|
|
769
|
+
"architects",
|
|
770
|
+
"consultant",
|
|
771
|
+
"consultants",
|
|
772
|
+
"specialist",
|
|
773
|
+
"specialists",
|
|
774
|
+
"coordinator",
|
|
775
|
+
"lead",
|
|
776
|
+
"principal",
|
|
777
|
+
"staff",
|
|
778
|
+
"junior",
|
|
779
|
+
"mid",
|
|
780
|
+
"department",
|
|
781
|
+
"organization",
|
|
782
|
+
"division",
|
|
783
|
+
"stakeholder",
|
|
784
|
+
"stakeholders",
|
|
785
|
+
"client",
|
|
786
|
+
"clients",
|
|
787
|
+
"customer",
|
|
788
|
+
"customers",
|
|
789
|
+
// Date & time words
|
|
790
|
+
"date",
|
|
791
|
+
"dates",
|
|
792
|
+
"month",
|
|
793
|
+
"months",
|
|
794
|
+
"week",
|
|
795
|
+
"weeks",
|
|
796
|
+
"daily",
|
|
797
|
+
"weekly",
|
|
798
|
+
"monthly",
|
|
799
|
+
"quarterly",
|
|
800
|
+
"annual",
|
|
801
|
+
"annually",
|
|
802
|
+
// More generic words that aren't skills
|
|
803
|
+
"code",
|
|
804
|
+
"coding",
|
|
805
|
+
"url",
|
|
806
|
+
"contact",
|
|
807
|
+
"information",
|
|
808
|
+
"apply",
|
|
809
|
+
"application",
|
|
810
|
+
"review",
|
|
811
|
+
"reviews",
|
|
812
|
+
"quality",
|
|
813
|
+
"scale",
|
|
814
|
+
"scalable",
|
|
815
|
+
"system",
|
|
816
|
+
"systems",
|
|
817
|
+
"solution",
|
|
818
|
+
"feature",
|
|
819
|
+
"features",
|
|
820
|
+
"project",
|
|
821
|
+
"projects",
|
|
822
|
+
"build",
|
|
823
|
+
"building",
|
|
824
|
+
"deliver",
|
|
825
|
+
"delivery",
|
|
826
|
+
"cross-functional"
|
|
827
|
+
]
|
|
828
|
+
};
|
|
829
|
+
var en_default = en;
|
|
830
|
+
|
|
831
|
+
// src/ats/i18n/de.ts
|
|
832
|
+
var de = {
|
|
833
|
+
actionVerbs: [
|
|
834
|
+
// Führung & Management
|
|
835
|
+
"geleitet",
|
|
836
|
+
"gef\xFChrt",
|
|
837
|
+
"koordiniert",
|
|
838
|
+
"organisiert",
|
|
839
|
+
"verwaltet",
|
|
840
|
+
"delegiert",
|
|
841
|
+
"beaufsichtigt",
|
|
842
|
+
"betreut",
|
|
843
|
+
"eingestellt",
|
|
844
|
+
"motiviert",
|
|
845
|
+
"verantwortet",
|
|
846
|
+
"gesteuert",
|
|
847
|
+
"\xFCberwacht",
|
|
848
|
+
"priorisiert",
|
|
849
|
+
"geplant",
|
|
850
|
+
// Technik & Entwicklung
|
|
851
|
+
"entwickelt",
|
|
852
|
+
"implementiert",
|
|
853
|
+
"programmiert",
|
|
854
|
+
"konfiguriert",
|
|
855
|
+
"automatisiert",
|
|
856
|
+
"deployt",
|
|
857
|
+
"gebaut",
|
|
858
|
+
"entworfen",
|
|
859
|
+
"integriert",
|
|
860
|
+
"migriert",
|
|
861
|
+
"modernisiert",
|
|
862
|
+
"optimiert",
|
|
863
|
+
"refaktoriert",
|
|
864
|
+
"skaliert",
|
|
865
|
+
"standardisiert",
|
|
866
|
+
"getestet",
|
|
867
|
+
"aufgebaut",
|
|
868
|
+
"eingef\xFChrt",
|
|
869
|
+
"bereitgestellt",
|
|
870
|
+
"umgesetzt",
|
|
871
|
+
// Leistung & Ergebnisse
|
|
872
|
+
"verbessert",
|
|
873
|
+
"gesteigert",
|
|
874
|
+
"reduziert",
|
|
875
|
+
"beschleunigt",
|
|
876
|
+
"erreicht",
|
|
877
|
+
"\xFCbertroffen",
|
|
878
|
+
"erweitert",
|
|
879
|
+
"vereinfacht",
|
|
880
|
+
"gel\xF6st",
|
|
881
|
+
"transformiert",
|
|
882
|
+
"erh\xF6ht",
|
|
883
|
+
"verdoppelt",
|
|
884
|
+
"verdreifacht",
|
|
885
|
+
"generiert",
|
|
886
|
+
"gespart",
|
|
887
|
+
"maximiert",
|
|
888
|
+
"minimiert",
|
|
889
|
+
"eliminiert",
|
|
890
|
+
"geliefert",
|
|
891
|
+
"abgeschlossen",
|
|
892
|
+
// Kommunikation & Zusammenarbeit
|
|
893
|
+
"beraten",
|
|
894
|
+
"pr\xE4sentiert",
|
|
895
|
+
"dokumentiert",
|
|
896
|
+
"geschult",
|
|
897
|
+
"trainiert",
|
|
898
|
+
"vermittelt",
|
|
899
|
+
"kommuniziert",
|
|
900
|
+
"verhandelt",
|
|
901
|
+
"zusammengearbeitet",
|
|
902
|
+
"unterst\xFCtzt",
|
|
903
|
+
"gef\xF6rdert",
|
|
904
|
+
"empfohlen",
|
|
905
|
+
"vorgestellt",
|
|
906
|
+
"publiziert",
|
|
907
|
+
// Analyse & Forschung
|
|
908
|
+
"analysiert",
|
|
909
|
+
"bewertet",
|
|
910
|
+
"evaluiert",
|
|
911
|
+
"untersucht",
|
|
912
|
+
"erforscht",
|
|
913
|
+
"identifiziert",
|
|
914
|
+
"gemessen",
|
|
915
|
+
"\xFCberwacht",
|
|
916
|
+
"validiert",
|
|
917
|
+
"verifiziert",
|
|
918
|
+
"gepr\xFCft",
|
|
919
|
+
"verglichen",
|
|
920
|
+
"recherchiert",
|
|
921
|
+
"quantifiziert",
|
|
922
|
+
// Kreation & Innovation
|
|
923
|
+
"konzipiert",
|
|
924
|
+
"erstellt",
|
|
925
|
+
"gestaltet",
|
|
926
|
+
"initiiert",
|
|
927
|
+
"innoviert",
|
|
928
|
+
"eingef\xFChrt",
|
|
929
|
+
"gegr\xFCndet",
|
|
930
|
+
"formuliert"
|
|
931
|
+
],
|
|
932
|
+
pronouns: [
|
|
933
|
+
"ich",
|
|
934
|
+
"mich",
|
|
935
|
+
"mir",
|
|
936
|
+
"mein",
|
|
937
|
+
"meine",
|
|
938
|
+
"meinem",
|
|
939
|
+
"meiner",
|
|
940
|
+
"meines",
|
|
941
|
+
"wir",
|
|
942
|
+
"unser",
|
|
943
|
+
"unsere"
|
|
944
|
+
],
|
|
945
|
+
stopWords: [
|
|
946
|
+
"ein",
|
|
947
|
+
"eine",
|
|
948
|
+
"einer",
|
|
949
|
+
"eines",
|
|
950
|
+
"einem",
|
|
951
|
+
"der",
|
|
952
|
+
"die",
|
|
953
|
+
"das",
|
|
954
|
+
"den",
|
|
955
|
+
"dem",
|
|
956
|
+
"des",
|
|
957
|
+
"und",
|
|
958
|
+
"oder",
|
|
959
|
+
"aber",
|
|
960
|
+
"in",
|
|
961
|
+
"an",
|
|
962
|
+
"auf",
|
|
963
|
+
"zu",
|
|
964
|
+
"f\xFCr",
|
|
965
|
+
"von",
|
|
966
|
+
"mit",
|
|
967
|
+
"bei",
|
|
968
|
+
"aus",
|
|
969
|
+
"ist",
|
|
970
|
+
"war",
|
|
971
|
+
"sind",
|
|
972
|
+
"waren",
|
|
973
|
+
"wird",
|
|
974
|
+
"wurde",
|
|
975
|
+
"werden",
|
|
976
|
+
"hat",
|
|
977
|
+
"hatte",
|
|
978
|
+
"haben",
|
|
979
|
+
"hatten",
|
|
980
|
+
"sein",
|
|
981
|
+
"kann",
|
|
982
|
+
"k\xF6nnte",
|
|
983
|
+
"soll",
|
|
984
|
+
"sollte",
|
|
985
|
+
"muss",
|
|
986
|
+
"musste",
|
|
987
|
+
"darf",
|
|
988
|
+
"diese",
|
|
989
|
+
"dieser",
|
|
990
|
+
"dieses",
|
|
991
|
+
"diesem",
|
|
992
|
+
"diesen",
|
|
993
|
+
"als",
|
|
994
|
+
"wenn",
|
|
995
|
+
"nicht",
|
|
996
|
+
"kein",
|
|
997
|
+
"keine",
|
|
998
|
+
"so",
|
|
999
|
+
"auch",
|
|
1000
|
+
"noch",
|
|
1001
|
+
"schon",
|
|
1002
|
+
"nach",
|
|
1003
|
+
"vor",
|
|
1004
|
+
"\xFCber",
|
|
1005
|
+
"unter",
|
|
1006
|
+
"zwischen",
|
|
1007
|
+
"durch",
|
|
1008
|
+
"ohne",
|
|
1009
|
+
"um",
|
|
1010
|
+
"bis",
|
|
1011
|
+
"alle",
|
|
1012
|
+
"jede",
|
|
1013
|
+
"jeder",
|
|
1014
|
+
"jedes",
|
|
1015
|
+
"mehr",
|
|
1016
|
+
"viel",
|
|
1017
|
+
"sehr"
|
|
1018
|
+
]
|
|
1019
|
+
};
|
|
1020
|
+
var de_default = de;
|
|
1021
|
+
|
|
1022
|
+
// src/ats/i18n/index.ts
|
|
1023
|
+
var languages = { en: en_default, de: de_default };
|
|
1024
|
+
function getLanguageData(language) {
|
|
1025
|
+
return languages[language] ?? languages["en"] ?? en_default;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// src/ats/checks/yoe.ts
|
|
1029
|
+
function computeYoeYears(work) {
|
|
1030
|
+
const ranges = (work || []).filter((w) => w.startDate).map((w) => {
|
|
1031
|
+
const s = new Date(w.startDate).getTime();
|
|
1032
|
+
const e = w.endDate ? new Date(w.endDate).getTime() : Date.now();
|
|
1033
|
+
return [s, e];
|
|
1034
|
+
}).sort((a, b) => a[0] - b[0]);
|
|
1035
|
+
if (ranges.length === 0) return 0;
|
|
1036
|
+
const merged = [ranges[0]];
|
|
1037
|
+
for (let i = 1; i < ranges.length; i++) {
|
|
1038
|
+
const last = merged[merged.length - 1];
|
|
1039
|
+
const curr = ranges[i];
|
|
1040
|
+
if (curr[0] <= last[1]) last[1] = Math.max(last[1], curr[1]);
|
|
1041
|
+
else merged.push(curr);
|
|
1042
|
+
}
|
|
1043
|
+
const ms = merged.reduce((sum, [s, e]) => sum + (e - s), 0);
|
|
1044
|
+
return ms / (1e3 * 60 * 60 * 24 * 365.25);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// src/ats/checks/recruiter.ts
|
|
1048
|
+
var wordCount = (s) => s.trim().split(/\s+/).filter(Boolean).length;
|
|
1049
|
+
var firstWord = (s) => s.trim().split(/\s+/)[0]?.toLowerCase().replace(/[^a-zA-ZäöüßÄÖÜàáâãéèêëíìîïóòôõúùûñç]/g, "") || "";
|
|
1050
|
+
function isSenior(resume, cfg) {
|
|
1051
|
+
return computeYoeYears(resume.work) >= cfg.thresholds.seniorYoeCutoff;
|
|
1052
|
+
}
|
|
1053
|
+
var summaryLength = (resume) => {
|
|
1054
|
+
const s = resume.basics?.summary?.trim();
|
|
1055
|
+
if (!s) {
|
|
1056
|
+
return {
|
|
1057
|
+
id: "summary-length",
|
|
1058
|
+
tier: "recruiter",
|
|
1059
|
+
weight: "medium",
|
|
1060
|
+
status: "fail",
|
|
1061
|
+
score: 0,
|
|
1062
|
+
message: "No professional summary.",
|
|
1063
|
+
hints: ["Add a 2-4 sentence summary (20-50 words) to basics.summary."]
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
const w = wordCount(s);
|
|
1067
|
+
if (w >= 20 && w <= 50) {
|
|
1068
|
+
return {
|
|
1069
|
+
id: "summary-length",
|
|
1070
|
+
tier: "recruiter",
|
|
1071
|
+
weight: "medium",
|
|
1072
|
+
status: "pass",
|
|
1073
|
+
score: 100,
|
|
1074
|
+
message: `Summary length good (${w} words).`,
|
|
1075
|
+
hints: []
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
if (w >= 10 && w <= 80) {
|
|
1079
|
+
return {
|
|
1080
|
+
id: "summary-length",
|
|
1081
|
+
tier: "recruiter",
|
|
1082
|
+
weight: "medium",
|
|
1083
|
+
status: "warn",
|
|
1084
|
+
score: 70,
|
|
1085
|
+
message: `Summary ${w < 20 ? "short" : "long"} (${w} words). Aim for 20-50.`,
|
|
1086
|
+
hints: [w < 20 ? "Expand to 20-50 words." : "Trim to the most impactful 20-50 words."]
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
return {
|
|
1090
|
+
id: "summary-length",
|
|
1091
|
+
tier: "recruiter",
|
|
1092
|
+
weight: "medium",
|
|
1093
|
+
status: "fail",
|
|
1094
|
+
score: Math.max(0, 100 - Math.abs(w - 35) * 2),
|
|
1095
|
+
message: `Summary length ${w} words is far from target.`,
|
|
1096
|
+
hints: ["Rewrite the summary in 2-4 sentences (20-50 words)."]
|
|
1097
|
+
};
|
|
1098
|
+
};
|
|
1099
|
+
var actionVerbStart = (resume, language) => {
|
|
1100
|
+
const verbs = new Set(getLanguageData(language).actionVerbs);
|
|
1101
|
+
const all = [];
|
|
1102
|
+
(resume.work || []).forEach((w, i) => {
|
|
1103
|
+
(w.highlights || []).forEach(
|
|
1104
|
+
(h, j) => all.push({ text: h, path: `work[${i}].highlights[${j}]` })
|
|
1105
|
+
);
|
|
1106
|
+
});
|
|
1107
|
+
(resume.projects || []).forEach((p, i) => {
|
|
1108
|
+
(p.highlights || []).forEach(
|
|
1109
|
+
(h, j) => all.push({ text: h, path: `projects[${i}].highlights[${j}]` })
|
|
1110
|
+
);
|
|
1111
|
+
});
|
|
1112
|
+
if (all.length === 0) {
|
|
1113
|
+
return {
|
|
1114
|
+
id: "action-verb-start",
|
|
1115
|
+
tier: "recruiter",
|
|
1116
|
+
weight: "medium",
|
|
1117
|
+
status: "skipped",
|
|
1118
|
+
score: 0,
|
|
1119
|
+
message: "No highlights.",
|
|
1120
|
+
hints: []
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
const without = all.filter((h) => !verbs.has(firstWord(h.text)));
|
|
1124
|
+
const passed = without.length === 0;
|
|
1125
|
+
return {
|
|
1126
|
+
id: "action-verb-start",
|
|
1127
|
+
tier: "recruiter",
|
|
1128
|
+
weight: "medium",
|
|
1129
|
+
status: passed ? "pass" : without.length / all.length > 0.3 ? "fail" : "warn",
|
|
1130
|
+
score: Math.round((all.length - without.length) / all.length * 100),
|
|
1131
|
+
message: passed ? "All highlights start with action verbs." : `${without.length} of ${all.length} highlights miss an action verb.`,
|
|
1132
|
+
hints: passed ? [] : without.slice(0, 3).map((h) => `${h.path}: start with an action verb instead of "${firstWord(h.text)}".`)
|
|
1133
|
+
};
|
|
1134
|
+
};
|
|
1135
|
+
var QUANT_RE = /\d+%?|\$[\d,]+|[\d,]+\+?\s*(users|clients|customers|people|team|members|projects|applications|servers|services|endpoints|requests|transactions)/i;
|
|
1136
|
+
var quantificationDensity = (resume) => {
|
|
1137
|
+
const all = [];
|
|
1138
|
+
(resume.work || []).forEach((w, i) => {
|
|
1139
|
+
(w.highlights || []).forEach(
|
|
1140
|
+
(h, j) => all.push({ text: h, path: `work[${i}].highlights[${j}]` })
|
|
1141
|
+
);
|
|
1142
|
+
});
|
|
1143
|
+
(resume.projects || []).forEach((p, i) => {
|
|
1144
|
+
(p.highlights || []).forEach(
|
|
1145
|
+
(h, j) => all.push({ text: h, path: `projects[${i}].highlights[${j}]` })
|
|
1146
|
+
);
|
|
1147
|
+
});
|
|
1148
|
+
if (all.length === 0) {
|
|
1149
|
+
return {
|
|
1150
|
+
id: "quantification-density",
|
|
1151
|
+
tier: "recruiter",
|
|
1152
|
+
weight: "high",
|
|
1153
|
+
status: "skipped",
|
|
1154
|
+
score: 0,
|
|
1155
|
+
message: "No highlights.",
|
|
1156
|
+
hints: []
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
const quantified = all.filter((h) => QUANT_RE.test(h.text));
|
|
1160
|
+
const ratio = quantified.length / all.length;
|
|
1161
|
+
const status = ratio >= 0.5 ? "pass" : ratio >= 0.3 ? "warn" : "fail";
|
|
1162
|
+
return {
|
|
1163
|
+
id: "quantification-density",
|
|
1164
|
+
tier: "recruiter",
|
|
1165
|
+
weight: "high",
|
|
1166
|
+
status,
|
|
1167
|
+
score: Math.min(100, Math.round(ratio * 200)),
|
|
1168
|
+
message: `${quantified.length}/${all.length} highlights quantified (${Math.round(ratio * 100)}%).`,
|
|
1169
|
+
hints: status === "pass" ? [] : all.filter((h) => !QUANT_RE.test(h.text)).slice(0, 3).map((h) => `${h.path}: add a number/metric.`)
|
|
1170
|
+
};
|
|
1171
|
+
};
|
|
1172
|
+
var pronounLeakage = (resume, language) => {
|
|
1173
|
+
const set = new Set(getLanguageData(language).pronouns);
|
|
1174
|
+
const blocks = [];
|
|
1175
|
+
if (resume.basics?.summary) blocks.push({ text: resume.basics.summary, path: "basics.summary" });
|
|
1176
|
+
(resume.work || []).forEach((w, i) => {
|
|
1177
|
+
if (w.summary) blocks.push({ text: w.summary, path: `work[${i}].summary` });
|
|
1178
|
+
(w.highlights || []).forEach(
|
|
1179
|
+
(h, j) => blocks.push({ text: h, path: `work[${i}].highlights[${j}]` })
|
|
1180
|
+
);
|
|
1181
|
+
});
|
|
1182
|
+
const hits = [];
|
|
1183
|
+
for (const b of blocks) {
|
|
1184
|
+
for (const w of b.text.toLowerCase().split(/\s+/)) {
|
|
1185
|
+
const clean = w.replace(/[^a-zA-ZäöüßÄÖÜ]/g, "");
|
|
1186
|
+
if (set.has(clean)) hits.push({ pronoun: clean, path: b.path });
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
const passed = hits.length === 0;
|
|
1190
|
+
return {
|
|
1191
|
+
id: "pronoun-leakage",
|
|
1192
|
+
tier: "recruiter",
|
|
1193
|
+
weight: "low",
|
|
1194
|
+
status: passed ? "pass" : hits.length > 3 ? "fail" : "warn",
|
|
1195
|
+
score: passed ? 100 : Math.max(0, 100 - hits.length * 15),
|
|
1196
|
+
message: passed ? "No first-person pronouns." : `${hits.length} pronoun(s): ${[...new Set(hits.map((h) => h.pronoun))].join(", ")}.`,
|
|
1197
|
+
hints: passed ? [] : hits.slice(0, 3).map((h) => `${h.path}: drop "${h.pronoun}" (convention, not ATS).`)
|
|
1198
|
+
};
|
|
1199
|
+
};
|
|
1200
|
+
var bulletsPerRole = (resume, _l, cfg) => {
|
|
1201
|
+
const work = resume.work || [];
|
|
1202
|
+
if (work.length === 0) {
|
|
1203
|
+
return {
|
|
1204
|
+
id: "bullets-per-role",
|
|
1205
|
+
tier: "recruiter",
|
|
1206
|
+
weight: "medium",
|
|
1207
|
+
status: "skipped",
|
|
1208
|
+
score: 0,
|
|
1209
|
+
message: "No work entries.",
|
|
1210
|
+
hints: []
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
const senior = isSenior(resume, cfg);
|
|
1214
|
+
const min = cfg.thresholds.bulletsPerRole.min;
|
|
1215
|
+
const max = senior ? cfg.thresholds.bulletsPerRole.seniorMax : cfg.thresholds.bulletsPerRole.max;
|
|
1216
|
+
const offenders = [];
|
|
1217
|
+
work.forEach((w, i) => {
|
|
1218
|
+
const n = (w.highlights || []).length;
|
|
1219
|
+
if (n < min || n > max) offenders.push({ path: `work[${i}]`, n });
|
|
1220
|
+
});
|
|
1221
|
+
const passed = offenders.length === 0;
|
|
1222
|
+
return {
|
|
1223
|
+
id: "bullets-per-role",
|
|
1224
|
+
tier: "recruiter",
|
|
1225
|
+
weight: "medium",
|
|
1226
|
+
status: passed ? "pass" : work.length === 1 ? "warn" : offenders.length > work.length / 2 ? "fail" : "warn",
|
|
1227
|
+
score: Math.round((work.length - offenders.length) / work.length * 100),
|
|
1228
|
+
message: passed ? `All roles have ${min}-${max} highlights.` : `${offenders.length} role(s) outside ${min}-${max} highlights.`,
|
|
1229
|
+
hints: passed ? [] : offenders.slice(0, 3).map((o) => `${o.path}: ${o.n} highlights, target ${min}-${max}.`)
|
|
1230
|
+
};
|
|
1231
|
+
};
|
|
1232
|
+
var wordCountTotal = (resume, _l, cfg) => {
|
|
1233
|
+
const senior = isSenior(resume, cfg);
|
|
1234
|
+
const min = cfg.thresholds.wordCount.min;
|
|
1235
|
+
const max = senior ? cfg.thresholds.wordCount.seniorMax : cfg.thresholds.wordCount.max;
|
|
1236
|
+
const parts = [];
|
|
1237
|
+
if (resume.basics?.summary) parts.push(resume.basics.summary);
|
|
1238
|
+
for (const w of resume.work || []) {
|
|
1239
|
+
if (w.summary) parts.push(w.summary);
|
|
1240
|
+
parts.push(...w.highlights || []);
|
|
1241
|
+
}
|
|
1242
|
+
for (const p of resume.projects || []) {
|
|
1243
|
+
if (p.description) parts.push(p.description);
|
|
1244
|
+
parts.push(...p.highlights || []);
|
|
1245
|
+
}
|
|
1246
|
+
const total = wordCount(parts.join(" "));
|
|
1247
|
+
const passed = total >= min && total <= max;
|
|
1248
|
+
const status = passed ? "pass" : total < min * 0.7 || total > max * 1.5 ? "fail" : "warn";
|
|
1249
|
+
return {
|
|
1250
|
+
id: "word-count-total",
|
|
1251
|
+
tier: "recruiter",
|
|
1252
|
+
weight: "low",
|
|
1253
|
+
status,
|
|
1254
|
+
score: passed ? 100 : Math.max(0, 100 - Math.round(Math.abs(total - (min + max) / 2) / 10)),
|
|
1255
|
+
message: `Resume body: ${total} words (target ${min}-${max}${senior ? ", senior" : ""}).`,
|
|
1256
|
+
hints: passed ? [] : [total < min ? "Add more depth to highlights." : "Trim less impactful highlights."]
|
|
1257
|
+
};
|
|
1258
|
+
};
|
|
1259
|
+
var highlightLength = (resume) => {
|
|
1260
|
+
const all = [];
|
|
1261
|
+
(resume.work || []).forEach((w, i) => {
|
|
1262
|
+
(w.highlights || []).forEach(
|
|
1263
|
+
(h, j) => all.push({ text: h, path: `work[${i}].highlights[${j}]` })
|
|
1264
|
+
);
|
|
1265
|
+
});
|
|
1266
|
+
if (all.length === 0) {
|
|
1267
|
+
return {
|
|
1268
|
+
id: "highlight-length",
|
|
1269
|
+
tier: "recruiter",
|
|
1270
|
+
weight: "low",
|
|
1271
|
+
status: "skipped",
|
|
1272
|
+
score: 0,
|
|
1273
|
+
message: "No highlights.",
|
|
1274
|
+
hints: []
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
const long = all.filter((h) => wordCount(h.text) > 30);
|
|
1278
|
+
const passed = long.length === 0;
|
|
1279
|
+
return {
|
|
1280
|
+
id: "highlight-length",
|
|
1281
|
+
tier: "recruiter",
|
|
1282
|
+
weight: "low",
|
|
1283
|
+
status: passed ? "pass" : long.length / all.length > 0.3 ? "fail" : "warn",
|
|
1284
|
+
score: Math.round((all.length - long.length) / all.length * 100),
|
|
1285
|
+
message: passed ? "All highlights at most 30 words." : `${long.length} highlight(s) over 30 words.`,
|
|
1286
|
+
hints: passed ? [] : long.slice(0, 3).map((h) => `${h.path}: trim to 30 words or fewer.`)
|
|
1287
|
+
};
|
|
1288
|
+
};
|
|
1289
|
+
function isLinkedInUrl(url) {
|
|
1290
|
+
if (!url) return false;
|
|
1291
|
+
try {
|
|
1292
|
+
const host = new URL(url).hostname.toLowerCase();
|
|
1293
|
+
return host === "linkedin.com" || host.endsWith(".linkedin.com");
|
|
1294
|
+
} catch {
|
|
1295
|
+
return false;
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
var hasLinkedin = (resume) => {
|
|
1299
|
+
const profiles = resume.basics?.profiles || [];
|
|
1300
|
+
const found = profiles.some(
|
|
1301
|
+
(p) => p.network?.toLowerCase() === "linkedin" || isLinkedInUrl(p.url)
|
|
1302
|
+
);
|
|
1303
|
+
return {
|
|
1304
|
+
id: "has-linkedin",
|
|
1305
|
+
tier: "recruiter",
|
|
1306
|
+
weight: "low",
|
|
1307
|
+
status: found ? "pass" : "warn",
|
|
1308
|
+
score: found ? 100 : 0,
|
|
1309
|
+
message: found ? "LinkedIn profile present." : "No LinkedIn profile.",
|
|
1310
|
+
hints: found ? [] : ["Add a LinkedIn profile to basics.profiles."]
|
|
1311
|
+
};
|
|
1312
|
+
};
|
|
1313
|
+
var skillsPopulated = (resume) => {
|
|
1314
|
+
const skills = resume.skills || [];
|
|
1315
|
+
const withKeywords = skills.filter((s) => (s.keywords?.length ?? 0) > 0);
|
|
1316
|
+
const passed = withKeywords.length >= 3;
|
|
1317
|
+
return {
|
|
1318
|
+
id: "skills-populated",
|
|
1319
|
+
tier: "recruiter",
|
|
1320
|
+
weight: "medium",
|
|
1321
|
+
status: passed ? "pass" : withKeywords.length < 2 ? "fail" : "warn",
|
|
1322
|
+
score: Math.min(100, Math.round(withKeywords.length / 3 * 100)),
|
|
1323
|
+
message: passed ? `${withKeywords.length} skill categories with keywords.` : `Only ${withKeywords.length} skill categories with keywords (need 3+).`,
|
|
1324
|
+
hints: passed ? [] : ["Add at least 3 skill categories with keywords."]
|
|
1325
|
+
};
|
|
1326
|
+
};
|
|
1327
|
+
var allRecruiterChecks = [
|
|
1328
|
+
summaryLength,
|
|
1329
|
+
actionVerbStart,
|
|
1330
|
+
quantificationDensity,
|
|
1331
|
+
pronounLeakage,
|
|
1332
|
+
bulletsPerRole,
|
|
1333
|
+
wordCountTotal,
|
|
1334
|
+
highlightLength,
|
|
1335
|
+
hasLinkedin,
|
|
1336
|
+
skillsPopulated
|
|
1337
|
+
];
|
|
1338
|
+
|
|
1339
|
+
// src/ats/jdMatcher.ts
|
|
1340
|
+
function extractResumeText(resume) {
|
|
1341
|
+
const parts = [];
|
|
1342
|
+
if (resume.basics?.summary) parts.push(resume.basics.summary);
|
|
1343
|
+
if (resume.basics?.label) parts.push(resume.basics.label);
|
|
1344
|
+
for (const w of resume.work || []) {
|
|
1345
|
+
if (w.position) parts.push(w.position);
|
|
1346
|
+
if (w.summary) parts.push(w.summary);
|
|
1347
|
+
parts.push(...w.highlights || []);
|
|
1348
|
+
}
|
|
1349
|
+
for (const s of resume.skills || []) {
|
|
1350
|
+
if (s.name) parts.push(s.name);
|
|
1351
|
+
parts.push(...s.keywords || []);
|
|
1352
|
+
}
|
|
1353
|
+
for (const p of resume.projects || []) {
|
|
1354
|
+
if (p.name) parts.push(p.name);
|
|
1355
|
+
if (p.description) parts.push(p.description);
|
|
1356
|
+
parts.push(...p.highlights || []);
|
|
1357
|
+
}
|
|
1358
|
+
for (const e of resume.education || []) {
|
|
1359
|
+
if (e.area) parts.push(e.area);
|
|
1360
|
+
if (e.studyType) parts.push(e.studyType);
|
|
1361
|
+
parts.push(...e.courses || []);
|
|
1362
|
+
}
|
|
1363
|
+
for (const c of resume.certificates || []) {
|
|
1364
|
+
if (c.name) parts.push(c.name);
|
|
1365
|
+
}
|
|
1366
|
+
return parts.join(" ");
|
|
1367
|
+
}
|
|
1368
|
+
function splitJdSections(text) {
|
|
1369
|
+
const lines = text.split("\n");
|
|
1370
|
+
const reqPatterns = /^(required|requirements?|minimum|preferred|qualifications?|must[\s-]have|nice[\s-]to[\s-]have|what you.?ll|what we.?re looking|skills|technical|you.?ll need|responsibilities|you will)/i;
|
|
1371
|
+
const nonReqPatterns = /^(about|summary|who we are|our (company|team|mission)|description|overview|benefits|perks|compensation|salary)/i;
|
|
1372
|
+
let inReqSection = false;
|
|
1373
|
+
const reqLines = [];
|
|
1374
|
+
for (const line of lines) {
|
|
1375
|
+
const header = line.trim().replace(/[:#*-]/g, "").trim();
|
|
1376
|
+
if (reqPatterns.test(header)) inReqSection = true;
|
|
1377
|
+
else if (nonReqPatterns.test(header)) inReqSection = false;
|
|
1378
|
+
if (inReqSection) reqLines.push(line);
|
|
1379
|
+
}
|
|
1380
|
+
return {
|
|
1381
|
+
requirementText: reqLines.join("\n"),
|
|
1382
|
+
fullText: text
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
function rankSkills(fullMatches, requirementMatches) {
|
|
1386
|
+
const reqIds = new Set(requirementMatches.map((m) => m.skill.id));
|
|
1387
|
+
return fullMatches.map((m) => {
|
|
1388
|
+
const inRequirementSection = reqIds.has(m.skill.id);
|
|
1389
|
+
const score = m.occurrences * 1 + (inRequirementSection ? 3 : 0) + (m.skill.hot ? 1.5 : 0);
|
|
1390
|
+
return {
|
|
1391
|
+
canonical: m.skill.canonical,
|
|
1392
|
+
occurrences: m.occurrences,
|
|
1393
|
+
inRequirementSection,
|
|
1394
|
+
hot: m.skill.hot,
|
|
1395
|
+
score
|
|
1396
|
+
};
|
|
1397
|
+
}).sort((a, b) => {
|
|
1398
|
+
if (a.inRequirementSection !== b.inRequirementSection) return a.inRequirementSection ? -1 : 1;
|
|
1399
|
+
if (a.score !== b.score) return b.score - a.score;
|
|
1400
|
+
return a.canonical.localeCompare(b.canonical);
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
function matchJobDescription(resume, jobDescription, _language = "en") {
|
|
1404
|
+
if (!jobDescription.trim()) {
|
|
1405
|
+
return { matched: [], missing: [], extra: [], matchPercentage: 0 };
|
|
1406
|
+
}
|
|
1407
|
+
const skillIndex = getSkillIndex();
|
|
1408
|
+
const fullMatches = skillIndex.scan(jobDescription);
|
|
1409
|
+
const { requirementText } = splitJdSections(jobDescription);
|
|
1410
|
+
const requirementMatches = requirementText.trim() ? skillIndex.scan(requirementText) : fullMatches;
|
|
1411
|
+
const ranked = rankSkills(fullMatches, requirementMatches).slice(0, 25);
|
|
1412
|
+
const resumeText = extractResumeText(resume);
|
|
1413
|
+
const resumeMatches = skillIndex.scan(resumeText);
|
|
1414
|
+
const resumeSkillIds = new Set(resumeMatches.map((m) => m.skill.id));
|
|
1415
|
+
const matched = [];
|
|
1416
|
+
const missing = [];
|
|
1417
|
+
for (const r of ranked) {
|
|
1418
|
+
const match = fullMatches.find((m) => m.skill.canonical === r.canonical);
|
|
1419
|
+
if (!match) continue;
|
|
1420
|
+
if (resumeSkillIds.has(match.skill.id)) matched.push(r.canonical);
|
|
1421
|
+
else missing.push(r.canonical);
|
|
1422
|
+
}
|
|
1423
|
+
const matchPercentage = ranked.length > 0 ? Math.round(matched.length / ranked.length * 100) : 0;
|
|
1424
|
+
const jdSkillIds = new Set(fullMatches.map((m) => m.skill.id));
|
|
1425
|
+
const extra = [];
|
|
1426
|
+
for (const m of resumeMatches) {
|
|
1427
|
+
if (!jdSkillIds.has(m.skill.id)) extra.push(m.skill.canonical);
|
|
1428
|
+
}
|
|
1429
|
+
extra.splice(25);
|
|
1430
|
+
return { matched, missing, extra, matchPercentage };
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
// src/ats/roleFamily.ts
|
|
1434
|
+
var SIGNATURES = {
|
|
1435
|
+
engineering: [
|
|
1436
|
+
"software engineer",
|
|
1437
|
+
"engineer",
|
|
1438
|
+
"developer",
|
|
1439
|
+
"programmer",
|
|
1440
|
+
"swe",
|
|
1441
|
+
"backend",
|
|
1442
|
+
"back-end",
|
|
1443
|
+
"frontend",
|
|
1444
|
+
"front-end",
|
|
1445
|
+
"full stack",
|
|
1446
|
+
"full-stack",
|
|
1447
|
+
"devops",
|
|
1448
|
+
"sre",
|
|
1449
|
+
"site reliability",
|
|
1450
|
+
"software development",
|
|
1451
|
+
"microservices",
|
|
1452
|
+
"codebase"
|
|
1453
|
+
],
|
|
1454
|
+
data: [
|
|
1455
|
+
"data scientist",
|
|
1456
|
+
"data engineer",
|
|
1457
|
+
"machine learning",
|
|
1458
|
+
"ml engineer",
|
|
1459
|
+
"data analyst",
|
|
1460
|
+
"analytics engineer",
|
|
1461
|
+
"mlops"
|
|
1462
|
+
],
|
|
1463
|
+
design: [
|
|
1464
|
+
"designer",
|
|
1465
|
+
"ux ",
|
|
1466
|
+
"ui designer",
|
|
1467
|
+
"product design",
|
|
1468
|
+
"graphic design",
|
|
1469
|
+
"visual design",
|
|
1470
|
+
"user research"
|
|
1471
|
+
],
|
|
1472
|
+
product: ["product manager", "product owner", "program manager", "product management"],
|
|
1473
|
+
recruiting: [
|
|
1474
|
+
"recruiter",
|
|
1475
|
+
"recruiting",
|
|
1476
|
+
"recruitment",
|
|
1477
|
+
"talent acquisition",
|
|
1478
|
+
"sourcer",
|
|
1479
|
+
"sourcing",
|
|
1480
|
+
"full-cycle recruiting",
|
|
1481
|
+
"candidate experience",
|
|
1482
|
+
"hiring manager",
|
|
1483
|
+
"people ops",
|
|
1484
|
+
"human resources"
|
|
1485
|
+
],
|
|
1486
|
+
sales: [
|
|
1487
|
+
"account executive",
|
|
1488
|
+
"business development",
|
|
1489
|
+
"sales development",
|
|
1490
|
+
"sales representative",
|
|
1491
|
+
"quota",
|
|
1492
|
+
"sdr",
|
|
1493
|
+
"bdr"
|
|
1494
|
+
],
|
|
1495
|
+
marketing: [
|
|
1496
|
+
"marketing",
|
|
1497
|
+
"growth marketing",
|
|
1498
|
+
"demand generation",
|
|
1499
|
+
"content marketing",
|
|
1500
|
+
"brand manager",
|
|
1501
|
+
"seo"
|
|
1502
|
+
],
|
|
1503
|
+
legal: ["legal counsel", "attorney", "paralegal", "lawyer", "litigation", "general counsel"],
|
|
1504
|
+
finance: ["accountant", "accounting", "controller", "fp&a", "financial analyst", "bookkeeping"],
|
|
1505
|
+
support: [
|
|
1506
|
+
"customer support",
|
|
1507
|
+
"customer success",
|
|
1508
|
+
"support specialist",
|
|
1509
|
+
"technical support",
|
|
1510
|
+
"help desk"
|
|
1511
|
+
],
|
|
1512
|
+
operations: ["operations manager", "business operations", "supply chain", "logistics"]
|
|
1513
|
+
};
|
|
1514
|
+
var FAMILIES = Object.keys(SIGNATURES);
|
|
1515
|
+
function countOccurrences(haystack, needle) {
|
|
1516
|
+
let count = 0;
|
|
1517
|
+
let idx = haystack.indexOf(needle);
|
|
1518
|
+
while (idx !== -1) {
|
|
1519
|
+
count++;
|
|
1520
|
+
idx = haystack.indexOf(needle, idx + needle.length);
|
|
1521
|
+
}
|
|
1522
|
+
return count;
|
|
1523
|
+
}
|
|
1524
|
+
function classifyRoleFamily(body, title = "") {
|
|
1525
|
+
const b = body.toLowerCase();
|
|
1526
|
+
const t = title.toLowerCase();
|
|
1527
|
+
const scores = FAMILIES.map((family) => {
|
|
1528
|
+
let score = 0;
|
|
1529
|
+
for (const sig of SIGNATURES[family]) {
|
|
1530
|
+
score += countOccurrences(b, sig);
|
|
1531
|
+
score += countOccurrences(t, sig) * 3;
|
|
1532
|
+
}
|
|
1533
|
+
return { family, score };
|
|
1534
|
+
}).sort((a, b2) => b2.score - a.score);
|
|
1535
|
+
const top = scores[0];
|
|
1536
|
+
const second = scores[1];
|
|
1537
|
+
if (!top || top.score < 2) return null;
|
|
1538
|
+
const margin = top.score - (second?.score ?? 0);
|
|
1539
|
+
if (margin < 1) return null;
|
|
1540
|
+
return { family: top.family, score: top.score, margin };
|
|
1541
|
+
}
|
|
1542
|
+
var SPECIALTY_SIGNATURES = {
|
|
1543
|
+
frontend: ["frontend", "front-end", "front end", "react", "vue", "angular", "css", "ui engineer", "web developer", "design system"],
|
|
1544
|
+
backend: ["backend", "back-end", "back end", "server-side", "microservices", "api development", "distributed systems"],
|
|
1545
|
+
security: ["security", "appsec", "application security", "infosec", "penetration", "vulnerability", "secure coding", "cryptography", "threat"],
|
|
1546
|
+
ml: ["machine learning", "ml engineer", "deep learning", "nlp", "computer vision", "ai engineer", "pytorch", "tensorflow"],
|
|
1547
|
+
mobile: ["ios engineer", "android engineer", "mobile engineer", "swift", "kotlin", "react native", "flutter"],
|
|
1548
|
+
devops: ["devops", "sre", "site reliability", "platform engineer", "kubernetes", "terraform", "infrastructure engineer"],
|
|
1549
|
+
data: ["data engineer", "etl", "data pipeline", "spark", "hadoop", "data warehouse"],
|
|
1550
|
+
embedded: ["embedded", "firmware", "rtos", "microcontroller", "bare metal"],
|
|
1551
|
+
qa: ["qa engineer", "test engineer", "sdet", "automation testing", "quality assurance"]
|
|
1552
|
+
};
|
|
1553
|
+
var SPECIALTIES = Object.keys(SPECIALTY_SIGNATURES);
|
|
1554
|
+
function classifySpecialty(body, title = "") {
|
|
1555
|
+
const b = body.toLowerCase();
|
|
1556
|
+
const t = title.toLowerCase();
|
|
1557
|
+
const scores = SPECIALTIES.map((sp) => {
|
|
1558
|
+
let score = 0;
|
|
1559
|
+
for (const sig of SPECIALTY_SIGNATURES[sp]) {
|
|
1560
|
+
score += countOccurrences(b, sig);
|
|
1561
|
+
score += countOccurrences(t, sig) * 3;
|
|
1562
|
+
}
|
|
1563
|
+
return { sp, score };
|
|
1564
|
+
}).sort((a, b2) => b2.score - a.score);
|
|
1565
|
+
const top = scores[0];
|
|
1566
|
+
const second = scores[1];
|
|
1567
|
+
if (!top || top.score < 2) return null;
|
|
1568
|
+
if (top.score - (second?.score ?? 0) < 1) return null;
|
|
1569
|
+
return top.sp;
|
|
1570
|
+
}
|
|
1571
|
+
var SPECIALTY_MIN_SCORE = 4;
|
|
1572
|
+
var TOP_MARGIN_FACTOR = 0.5;
|
|
1573
|
+
var SPECIALTY_ABS_MARGIN = 3;
|
|
1574
|
+
function resumeSpecialties(resume) {
|
|
1575
|
+
const text = resumeRoleText(resume).toLowerCase();
|
|
1576
|
+
const scored = SPECIALTIES.map((sp) => {
|
|
1577
|
+
let score = 0;
|
|
1578
|
+
for (const sig of SPECIALTY_SIGNATURES[sp]) score += countOccurrences(text, sig);
|
|
1579
|
+
return { sp, score };
|
|
1580
|
+
}).sort((a, b) => b.score - a.score);
|
|
1581
|
+
const top = scored[0];
|
|
1582
|
+
if (!top || top.score < SPECIALTY_MIN_SCORE) return [];
|
|
1583
|
+
const margin = Math.min(SPECIALTY_ABS_MARGIN, Math.max(TOP_MARGIN_FACTOR * top.score, 1));
|
|
1584
|
+
const threshold = top.score - margin;
|
|
1585
|
+
return scored.filter((s) => s.score >= threshold).map((s) => s.sp);
|
|
1586
|
+
}
|
|
1587
|
+
function resumeRoleText(resume) {
|
|
1588
|
+
const parts = [];
|
|
1589
|
+
if (resume.basics?.label) parts.push(resume.basics.label);
|
|
1590
|
+
if (resume.basics?.summary) parts.push(resume.basics.summary);
|
|
1591
|
+
for (const w of resume.work ?? []) {
|
|
1592
|
+
if (w.position) parts.push(w.position);
|
|
1593
|
+
if (w.summary) parts.push(w.summary);
|
|
1594
|
+
parts.push(...w.highlights ?? []);
|
|
1595
|
+
}
|
|
1596
|
+
for (const s of resume.skills ?? []) {
|
|
1597
|
+
if (s.name) parts.push(s.name);
|
|
1598
|
+
parts.push(...s.keywords ?? []);
|
|
1599
|
+
}
|
|
1600
|
+
return parts.join(" ");
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// src/ats/checks/match.ts
|
|
1604
|
+
var SENIORITY = /\b(junior|senior|lead|staff|principal|head of|vp|chief)\b/gi;
|
|
1605
|
+
var STOPWORDS = /* @__PURE__ */ new Set([
|
|
1606
|
+
"a",
|
|
1607
|
+
"an",
|
|
1608
|
+
"the",
|
|
1609
|
+
"of",
|
|
1610
|
+
"at",
|
|
1611
|
+
"for",
|
|
1612
|
+
"in",
|
|
1613
|
+
"on",
|
|
1614
|
+
"to",
|
|
1615
|
+
"with",
|
|
1616
|
+
"and",
|
|
1617
|
+
"or"
|
|
1618
|
+
]);
|
|
1619
|
+
function tokenize(s) {
|
|
1620
|
+
return s.toLowerCase().replace(SENIORITY, "").replace(/[^a-z0-9 ]/g, " ").split(/\s+/).filter((w) => w && !STOPWORDS.has(w));
|
|
1621
|
+
}
|
|
1622
|
+
function jaccard(a, b) {
|
|
1623
|
+
const A = new Set(a), B = new Set(b);
|
|
1624
|
+
let inter = 0;
|
|
1625
|
+
for (const x of A) if (B.has(x)) inter++;
|
|
1626
|
+
const union = A.size + B.size - inter;
|
|
1627
|
+
return union === 0 ? 0 : inter / union;
|
|
1628
|
+
}
|
|
1629
|
+
function trimTitle(t) {
|
|
1630
|
+
return t.trim().replace(/\s*[,\-–—]\s.*$/, "").trim();
|
|
1631
|
+
}
|
|
1632
|
+
function extractJdTitle(jd) {
|
|
1633
|
+
const lines = jd.split("\n").slice(0, 8);
|
|
1634
|
+
for (const l of lines) {
|
|
1635
|
+
const m = l.match(/(?:role|position|title)[\s:-]+(.+)/i) || l.match(/looking for (?:an? )?(.+?)(?:\s+with|\s+to|$)/i);
|
|
1636
|
+
if (m?.[1]) return trimTitle(m[1]);
|
|
1637
|
+
}
|
|
1638
|
+
const fallback = lines.find(
|
|
1639
|
+
(l) => /\b(engineer|developer|manager|designer|analyst|scientist|architect|lead)\b/i.test(l)
|
|
1640
|
+
);
|
|
1641
|
+
return fallback ? trimTitle(fallback) : void 0;
|
|
1642
|
+
}
|
|
1643
|
+
var titleAlignment = (resume, _l, { jobDescription, jobTitle }) => {
|
|
1644
|
+
if (!jobDescription) {
|
|
1645
|
+
return {
|
|
1646
|
+
id: "title-alignment",
|
|
1647
|
+
tier: "match",
|
|
1648
|
+
weight: "high",
|
|
1649
|
+
status: "skipped",
|
|
1650
|
+
score: 0,
|
|
1651
|
+
message: "No JD.",
|
|
1652
|
+
hints: []
|
|
1653
|
+
};
|
|
1654
|
+
}
|
|
1655
|
+
const resumeTitle = resume.work?.[0]?.position || resume.basics?.label;
|
|
1656
|
+
const jdTitle = jobTitle?.trim() ? trimTitle(jobTitle) : extractJdTitle(jobDescription);
|
|
1657
|
+
if (!resumeTitle || !jdTitle) {
|
|
1658
|
+
return {
|
|
1659
|
+
id: "title-alignment",
|
|
1660
|
+
tier: "match",
|
|
1661
|
+
weight: "high",
|
|
1662
|
+
status: "skipped",
|
|
1663
|
+
score: 0,
|
|
1664
|
+
message: "Could not extract a title from the JD or resume.",
|
|
1665
|
+
hints: ["Set basics.label to your target title, or supply the posting title."]
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1668
|
+
const j = jaccard(tokenize(resumeTitle), tokenize(jdTitle));
|
|
1669
|
+
const status = j >= 0.6 ? "pass" : j >= 0.3 ? "warn" : "fail";
|
|
1670
|
+
return {
|
|
1671
|
+
id: "title-alignment",
|
|
1672
|
+
tier: "match",
|
|
1673
|
+
weight: "high",
|
|
1674
|
+
status,
|
|
1675
|
+
score: Math.round(j * 100),
|
|
1676
|
+
message: `Title overlap ${Math.round(j * 100)}% (resume "${resumeTitle}" vs JD "${jdTitle}").`,
|
|
1677
|
+
hints: status === "pass" ? [] : [`Consider aligning basics.label closer to "${jdTitle}".`]
|
|
1678
|
+
};
|
|
1679
|
+
};
|
|
1680
|
+
var EDU_RE = {
|
|
1681
|
+
3: /\b(phd|ph\.?d|doctorate|doctoral)\b/i,
|
|
1682
|
+
2: /\b(master|m\.?s|m\.?a|mba|graduate degree)\b/i,
|
|
1683
|
+
1: /\b(bachelor|b\.?s|b\.?a|undergraduate)\b/i
|
|
1684
|
+
};
|
|
1685
|
+
function eduLevel(text) {
|
|
1686
|
+
if (!text) return 0;
|
|
1687
|
+
if (EDU_RE[3].test(text)) return 3;
|
|
1688
|
+
if (EDU_RE[2].test(text)) return 2;
|
|
1689
|
+
if (EDU_RE[1].test(text)) return 1;
|
|
1690
|
+
return 0;
|
|
1691
|
+
}
|
|
1692
|
+
var educationLevel = (resume, _l, { jobDescription }) => {
|
|
1693
|
+
if (!jobDescription) {
|
|
1694
|
+
return {
|
|
1695
|
+
id: "education-level",
|
|
1696
|
+
tier: "match",
|
|
1697
|
+
weight: "medium",
|
|
1698
|
+
status: "skipped",
|
|
1699
|
+
score: 0,
|
|
1700
|
+
message: "No JD.",
|
|
1701
|
+
hints: []
|
|
1702
|
+
};
|
|
1703
|
+
}
|
|
1704
|
+
const required = eduLevel(jobDescription);
|
|
1705
|
+
if (required === 0) {
|
|
1706
|
+
return {
|
|
1707
|
+
id: "education-level",
|
|
1708
|
+
tier: "match",
|
|
1709
|
+
weight: "medium",
|
|
1710
|
+
status: "skipped",
|
|
1711
|
+
score: 0,
|
|
1712
|
+
message: "JD does not specify education level.",
|
|
1713
|
+
hints: []
|
|
1714
|
+
};
|
|
1715
|
+
}
|
|
1716
|
+
const have = Math.max(0, ...(resume.education || []).map((e) => eduLevel(e.studyType || "")));
|
|
1717
|
+
const passed = have >= required;
|
|
1718
|
+
return {
|
|
1719
|
+
id: "education-level",
|
|
1720
|
+
tier: "match",
|
|
1721
|
+
weight: "medium",
|
|
1722
|
+
status: passed ? "pass" : "fail",
|
|
1723
|
+
score: passed ? 100 : Math.round(have / required * 100),
|
|
1724
|
+
message: `Resume level ${have}, JD required ${required}.`,
|
|
1725
|
+
hints: passed ? [] : ["JD requires a higher degree level than the resume reports."]
|
|
1726
|
+
};
|
|
1727
|
+
};
|
|
1728
|
+
var YOE_RE = /(\d+)\s*(?:\+|[-–—]\s*\d+|to\s*\d+)?\s*years?/i;
|
|
1729
|
+
var yoeMatch = (resume, _l, { jobDescription }) => {
|
|
1730
|
+
if (!jobDescription) {
|
|
1731
|
+
return {
|
|
1732
|
+
id: "yoe-match",
|
|
1733
|
+
tier: "match",
|
|
1734
|
+
weight: "high",
|
|
1735
|
+
status: "skipped",
|
|
1736
|
+
score: 0,
|
|
1737
|
+
message: "No JD.",
|
|
1738
|
+
hints: []
|
|
1739
|
+
};
|
|
1740
|
+
}
|
|
1741
|
+
const m = jobDescription.match(YOE_RE);
|
|
1742
|
+
if (!m) {
|
|
1743
|
+
return {
|
|
1744
|
+
id: "yoe-match",
|
|
1745
|
+
tier: "match",
|
|
1746
|
+
weight: "high",
|
|
1747
|
+
status: "skipped",
|
|
1748
|
+
score: 0,
|
|
1749
|
+
message: "JD does not specify years requirement.",
|
|
1750
|
+
hints: []
|
|
1751
|
+
};
|
|
1752
|
+
}
|
|
1753
|
+
const required = parseInt(m[1] ?? "0", 10);
|
|
1754
|
+
const have = Math.floor(computeYoeYears(resume.work || []));
|
|
1755
|
+
const status = have >= required ? "pass" : have >= required - 1 ? "warn" : "fail";
|
|
1756
|
+
return {
|
|
1757
|
+
id: "yoe-match",
|
|
1758
|
+
tier: "match",
|
|
1759
|
+
weight: "high",
|
|
1760
|
+
status,
|
|
1761
|
+
score: Math.min(100, Math.round(have / required * 100)),
|
|
1762
|
+
message: `${have} YOE detected vs ${required} required.`,
|
|
1763
|
+
hints: status === "pass" ? [] : ["Highlight relevant earlier roles or projects to fill the gap."]
|
|
1764
|
+
};
|
|
1765
|
+
};
|
|
1766
|
+
var hardSkillOverlap = (resume, language, { jobDescription }) => {
|
|
1767
|
+
if (!jobDescription) {
|
|
1768
|
+
return {
|
|
1769
|
+
id: "hard-skill-overlap",
|
|
1770
|
+
tier: "match",
|
|
1771
|
+
weight: "high",
|
|
1772
|
+
status: "skipped",
|
|
1773
|
+
score: 0,
|
|
1774
|
+
message: "No JD.",
|
|
1775
|
+
hints: []
|
|
1776
|
+
};
|
|
1777
|
+
}
|
|
1778
|
+
const km = matchJobDescription(resume, jobDescription, language);
|
|
1779
|
+
const pct = km.matchPercentage;
|
|
1780
|
+
const status = pct >= 70 ? "pass" : pct >= 50 ? "warn" : "fail";
|
|
1781
|
+
return {
|
|
1782
|
+
id: "hard-skill-overlap",
|
|
1783
|
+
tier: "match",
|
|
1784
|
+
weight: "high",
|
|
1785
|
+
status,
|
|
1786
|
+
score: pct,
|
|
1787
|
+
message: `${km.matched.length}/${km.matched.length + km.missing.length} hard skills matched (${pct}%).`,
|
|
1788
|
+
hints: status === "pass" ? [] : km.missing.slice(0, 5).map((s) => `Add evidence of "${s}" to skills/highlights.`)
|
|
1789
|
+
};
|
|
1790
|
+
};
|
|
1791
|
+
var roleFamilyMatch = (resume, _l, { jobDescription, jobTitle }) => {
|
|
1792
|
+
if (!jobDescription) {
|
|
1793
|
+
return {
|
|
1794
|
+
id: "role-family-match",
|
|
1795
|
+
tier: "match",
|
|
1796
|
+
weight: "high",
|
|
1797
|
+
status: "skipped",
|
|
1798
|
+
score: 0,
|
|
1799
|
+
message: "No JD.",
|
|
1800
|
+
hints: []
|
|
1801
|
+
};
|
|
1802
|
+
}
|
|
1803
|
+
const jd = classifyRoleFamily(jobDescription, jobTitle ?? "");
|
|
1804
|
+
const cv = classifyRoleFamily(resumeRoleText(resume));
|
|
1805
|
+
if (!jd || !cv) {
|
|
1806
|
+
return {
|
|
1807
|
+
id: "role-family-match",
|
|
1808
|
+
tier: "match",
|
|
1809
|
+
weight: "high",
|
|
1810
|
+
status: "skipped",
|
|
1811
|
+
score: 0,
|
|
1812
|
+
message: "Could not confidently classify the role family of the JD or resume.",
|
|
1813
|
+
hints: []
|
|
1814
|
+
};
|
|
1815
|
+
}
|
|
1816
|
+
if (jd.family !== cv.family) {
|
|
1817
|
+
return {
|
|
1818
|
+
id: "role-family-match",
|
|
1819
|
+
tier: "match",
|
|
1820
|
+
weight: "high",
|
|
1821
|
+
status: "fail",
|
|
1822
|
+
score: 0,
|
|
1823
|
+
message: `Role mismatch: ${cv.family} resume vs ${jd.family} role.`,
|
|
1824
|
+
hints: [
|
|
1825
|
+
`This posting reads as a ${jd.family} role; your resume reads as ${cv.family}. Likely not a fit.`
|
|
1826
|
+
]
|
|
1827
|
+
};
|
|
1828
|
+
}
|
|
1829
|
+
if (cv.family === "engineering") {
|
|
1830
|
+
const jdSpec = classifySpecialty(jobDescription, jobTitle ?? "");
|
|
1831
|
+
if (jdSpec) {
|
|
1832
|
+
const cvTopSpecs = resumeSpecialties(resume);
|
|
1833
|
+
const satisfied = cvTopSpecs.length === 0 || cvTopSpecs.includes(jdSpec);
|
|
1834
|
+
if (!satisfied) {
|
|
1835
|
+
return {
|
|
1836
|
+
id: "role-family-match",
|
|
1837
|
+
tier: "match",
|
|
1838
|
+
weight: "high",
|
|
1839
|
+
status: "fail",
|
|
1840
|
+
score: 0,
|
|
1841
|
+
message: `Specialty mismatch: ${jdSpec} role, resume specializes in ${cvTopSpecs[0] ?? "general engineering"}.`,
|
|
1842
|
+
hints: [
|
|
1843
|
+
`This is a ${jdSpec} engineering role; your resume doesn't show ${jdSpec} depth. Likely not a fit.`
|
|
1844
|
+
]
|
|
1845
|
+
};
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
return {
|
|
1850
|
+
id: "role-family-match",
|
|
1851
|
+
tier: "match",
|
|
1852
|
+
weight: "high",
|
|
1853
|
+
status: "pass",
|
|
1854
|
+
score: 100,
|
|
1855
|
+
message: `Role family aligned (${cv.family}).`,
|
|
1856
|
+
hints: []
|
|
1857
|
+
};
|
|
1858
|
+
};
|
|
1859
|
+
var allMatchChecks = [
|
|
1860
|
+
hardSkillOverlap,
|
|
1861
|
+
titleAlignment,
|
|
1862
|
+
roleFamilyMatch,
|
|
1863
|
+
educationLevel,
|
|
1864
|
+
yoeMatch
|
|
1865
|
+
];
|
|
1866
|
+
var KNOCKOUTS = [
|
|
1867
|
+
{
|
|
1868
|
+
signal: "work-auth",
|
|
1869
|
+
jdPattern: /(work\s*auth|authorization to work|right to work|us citizen|green card|h-?1b|visa sponsorship)/i,
|
|
1870
|
+
resumeMatch: (r) => /(work auth|authorized|citizen|green card|visa)/i.test(r.basics?.summary || ""),
|
|
1871
|
+
recommendation: "Confirm authorization status in the application form."
|
|
1872
|
+
},
|
|
1873
|
+
{
|
|
1874
|
+
signal: "location",
|
|
1875
|
+
jdPattern: /(must be located|on-?site|relocate|based in)\s+([a-zA-Z ,]+)/i,
|
|
1876
|
+
resumeMatch: (r, m) => {
|
|
1877
|
+
const jdLoc = (m[2] ?? "").toLowerCase().trim();
|
|
1878
|
+
const resumeCity = (r.basics?.location?.city ?? "").toLowerCase().trim();
|
|
1879
|
+
if (!jdLoc || !resumeCity) return false;
|
|
1880
|
+
return jdLoc.includes(resumeCity) || resumeCity.includes(jdLoc.split(/[, ]+/)[0] ?? "");
|
|
1881
|
+
},
|
|
1882
|
+
recommendation: "Verify location requirement against your basics.location.city."
|
|
1883
|
+
},
|
|
1884
|
+
{
|
|
1885
|
+
signal: "clearance",
|
|
1886
|
+
jdPattern: /(ts\/sci|secret clearance|security clearance|active clearance)/i,
|
|
1887
|
+
resumeMatch: (r) => /clearance/i.test(JSON.stringify(r)),
|
|
1888
|
+
recommendation: "Confirm clearance level on the application form."
|
|
1889
|
+
},
|
|
1890
|
+
{
|
|
1891
|
+
signal: "certification",
|
|
1892
|
+
jdPattern: /(cissp|aws certified|pmp|ccna|cpa|required certification)/i,
|
|
1893
|
+
resumeMatch: (r) => (r.certificates?.length ?? 0) > 0,
|
|
1894
|
+
recommendation: "List required certificates in the certificates section if held."
|
|
1895
|
+
}
|
|
1896
|
+
];
|
|
1897
|
+
function extractKnockouts(resume, jobDescription) {
|
|
1898
|
+
const out = [];
|
|
1899
|
+
for (const k of KNOCKOUTS) {
|
|
1900
|
+
const m = jobDescription.match(k.jdPattern);
|
|
1901
|
+
if (!m) continue;
|
|
1902
|
+
if (k.resumeMatch(resume, m)) continue;
|
|
1903
|
+
out.push({
|
|
1904
|
+
signal: k.signal,
|
|
1905
|
+
evidence: `JD: "${m[0]}"; resume silent.`,
|
|
1906
|
+
recommendation: k.recommendation
|
|
1907
|
+
});
|
|
1908
|
+
}
|
|
1909
|
+
return out;
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
// src/ats/scoring.ts
|
|
1913
|
+
var weightMultiplier = {
|
|
1914
|
+
high: 3,
|
|
1915
|
+
medium: 2,
|
|
1916
|
+
low: 1
|
|
1917
|
+
};
|
|
1918
|
+
function gradeFromScore(score, t) {
|
|
1919
|
+
if (score >= t.A) return "A";
|
|
1920
|
+
if (score >= t.B) return "B";
|
|
1921
|
+
if (score >= t.C) return "C";
|
|
1922
|
+
if (score >= t.D) return "D";
|
|
1923
|
+
return "F";
|
|
1924
|
+
}
|
|
1925
|
+
function computeTierScore(checks) {
|
|
1926
|
+
const active = checks.filter((c) => c.status !== "skipped");
|
|
1927
|
+
if (active.length === 0) return 0;
|
|
1928
|
+
let weighted = 0;
|
|
1929
|
+
let total = 0;
|
|
1930
|
+
for (const c of active) {
|
|
1931
|
+
const m = weightMultiplier[c.weight];
|
|
1932
|
+
weighted += c.score * m;
|
|
1933
|
+
total += 100 * m;
|
|
1934
|
+
}
|
|
1935
|
+
return total > 0 ? Math.round(weighted / total * 100) : 0;
|
|
1936
|
+
}
|
|
1937
|
+
function computeTotalScore(tiers, weights) {
|
|
1938
|
+
if (tiers.match === void 0) {
|
|
1939
|
+
const sum2 = weights.parsing + weights.recruiter;
|
|
1940
|
+
const wp = weights.parsing / sum2;
|
|
1941
|
+
const wr = weights.recruiter / sum2;
|
|
1942
|
+
return Math.round(
|
|
1943
|
+
tiers.parsing * (sum2 === 50 ? 0.4 : wp) + tiers.recruiter * (sum2 === 50 ? 0.6 : wr)
|
|
1944
|
+
);
|
|
1945
|
+
}
|
|
1946
|
+
const sum = weights.parsing + weights.match + weights.recruiter;
|
|
1947
|
+
return Math.round(
|
|
1948
|
+
(tiers.parsing * weights.parsing + tiers.match * weights.match + tiers.recruiter * weights.recruiter) / sum
|
|
1949
|
+
);
|
|
1950
|
+
}
|
|
1951
|
+
function scoreToRating(score, t) {
|
|
1952
|
+
if (score >= t.excellent) return "excellent";
|
|
1953
|
+
if (score >= t.good) return "good";
|
|
1954
|
+
if (score >= t.needsWork) return "needs-work";
|
|
1955
|
+
return "poor";
|
|
1956
|
+
}
|
|
1957
|
+
function generateSummary(score, rating, hasJd, knockouts) {
|
|
1958
|
+
const ratingLabel = {
|
|
1959
|
+
excellent: "Excellent",
|
|
1960
|
+
good: "Good",
|
|
1961
|
+
"needs-work": "Needs Work",
|
|
1962
|
+
poor: "Poor"
|
|
1963
|
+
}[rating];
|
|
1964
|
+
const knockoutNote = knockouts > 0 ? ` ${knockouts} knockout signal${knockouts === 1 ? "" : "s"} flagged.` : "";
|
|
1965
|
+
return `ATS ${score}/100 (${ratingLabel}).${hasJd ? " Includes JD match." : ""}${knockoutNote}`;
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
// src/utils/config.ts
|
|
1969
|
+
import fs from "fs";
|
|
1970
|
+
import path from "path";
|
|
1971
|
+
import yaml from "yaml";
|
|
1972
|
+
import { z } from "zod";
|
|
1973
|
+
import merge from "lodash.merge";
|
|
1974
|
+
var weightEnum = z.enum(["high", "medium", "low"]);
|
|
1975
|
+
var atsConfigSchema = z.object({
|
|
1976
|
+
weights: z.object({
|
|
1977
|
+
tiers: z.object({
|
|
1978
|
+
parsing: z.number().int().min(0).max(100),
|
|
1979
|
+
match: z.number().int().min(0).max(100),
|
|
1980
|
+
recruiter: z.number().int().min(0).max(100)
|
|
1981
|
+
}).partial().optional(),
|
|
1982
|
+
checks: z.record(z.string(), weightEnum).optional()
|
|
1983
|
+
}).partial().optional(),
|
|
1984
|
+
thresholds: z.object({
|
|
1985
|
+
rating: z.object({
|
|
1986
|
+
excellent: z.number(),
|
|
1987
|
+
good: z.number(),
|
|
1988
|
+
needsWork: z.number()
|
|
1989
|
+
}).partial().optional(),
|
|
1990
|
+
grade: z.object({ A: z.number(), B: z.number(), C: z.number(), D: z.number() }).partial().optional(),
|
|
1991
|
+
seniorYoeCutoff: z.number().int().min(0).optional(),
|
|
1992
|
+
wordCount: z.object({ min: z.number(), max: z.number(), seniorMax: z.number() }).partial().optional(),
|
|
1993
|
+
bulletsPerRole: z.object({ min: z.number(), max: z.number(), seniorMax: z.number() }).partial().optional()
|
|
1994
|
+
}).partial().optional(),
|
|
1995
|
+
disable: z.array(z.string()).optional(),
|
|
1996
|
+
locale: z.string().optional()
|
|
1997
|
+
});
|
|
1998
|
+
var fileSchema = z.object({ ats: atsConfigSchema.optional() });
|
|
1999
|
+
var defaultConfig = {
|
|
2000
|
+
weights: {
|
|
2001
|
+
tiers: { parsing: 30, match: 50, recruiter: 20 },
|
|
2002
|
+
checks: {}
|
|
2003
|
+
},
|
|
2004
|
+
thresholds: {
|
|
2005
|
+
rating: { excellent: 90, good: 75, needsWork: 60 },
|
|
2006
|
+
grade: { A: 90, B: 80, C: 70, D: 60 },
|
|
2007
|
+
seniorYoeCutoff: 10,
|
|
2008
|
+
wordCount: { min: 400, max: 800, seniorMax: 1600 },
|
|
2009
|
+
bulletsPerRole: { min: 3, max: 6, seniorMax: 10 }
|
|
2010
|
+
},
|
|
2011
|
+
disable: [],
|
|
2012
|
+
locale: "en"
|
|
2013
|
+
};
|
|
2014
|
+
function loadConfig(opts = {}) {
|
|
2015
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
2016
|
+
const file = opts.configPath ?? path.join(cwd, "resuml.config.yaml");
|
|
2017
|
+
if (!fs.existsSync(file)) return defaultConfig;
|
|
2018
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
2019
|
+
const parsed = yaml.parse(raw) ?? {};
|
|
2020
|
+
const result = fileSchema.safeParse(parsed);
|
|
2021
|
+
if (!result.success) {
|
|
2022
|
+
const issue = result.error.issues[0];
|
|
2023
|
+
const where = issue?.path.join(".") ?? "<root>";
|
|
2024
|
+
throw new Error(`Invalid resuml.config.yaml at "${where}": ${issue?.message}`);
|
|
2025
|
+
}
|
|
2026
|
+
return merge({}, defaultConfig, result.data.ats ?? {});
|
|
2027
|
+
}
|
|
2028
|
+
function effectiveWeight(checkId, defaultWeight, config) {
|
|
2029
|
+
return config.weights.checks[checkId] ?? defaultWeight;
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
// src/ats/index.ts
|
|
2033
|
+
function applyConfig(checks, cfg) {
|
|
2034
|
+
return checks.filter((c) => !cfg.disable.includes(c.id)).map((c) => ({ ...c, weight: effectiveWeight(c.id, c.weight, cfg) }));
|
|
2035
|
+
}
|
|
2036
|
+
function buildTier(_tier, checks, cfg) {
|
|
2037
|
+
const filtered = applyConfig(checks, cfg);
|
|
2038
|
+
const score = computeTierScore(filtered);
|
|
2039
|
+
return {
|
|
2040
|
+
score,
|
|
2041
|
+
grade: gradeFromScore(score, cfg.thresholds.grade),
|
|
2042
|
+
checks: filtered
|
|
2043
|
+
};
|
|
2044
|
+
}
|
|
2045
|
+
function analyzeAts(resume, options = {}) {
|
|
2046
|
+
const cfg = options.config ?? defaultConfig;
|
|
2047
|
+
const language = options.language ?? cfg.locale;
|
|
2048
|
+
const parsingChecks = allParsingChecks.map((fn) => fn(resume, language));
|
|
2049
|
+
const recruiterChecks = allRecruiterChecks.map((fn) => fn(resume, language, cfg));
|
|
2050
|
+
const parsing = buildTier("parsing", parsingChecks, cfg);
|
|
2051
|
+
const recruiter = buildTier("recruiter", recruiterChecks, cfg);
|
|
2052
|
+
let match;
|
|
2053
|
+
let knockouts = [];
|
|
2054
|
+
let roleMismatch = false;
|
|
2055
|
+
if (options.jobDescription) {
|
|
2056
|
+
const matchChecks = allMatchChecks.map(
|
|
2057
|
+
(fn) => fn(resume, language, { jobDescription: options.jobDescription, jobTitle: options.jobTitle })
|
|
2058
|
+
);
|
|
2059
|
+
match = buildTier("match", matchChecks, cfg);
|
|
2060
|
+
roleMismatch = matchChecks.some(
|
|
2061
|
+
(c) => c.id === "role-family-match" && c.status === "fail"
|
|
2062
|
+
);
|
|
2063
|
+
knockouts = extractKnockouts(resume, options.jobDescription);
|
|
2064
|
+
}
|
|
2065
|
+
let totalScore = computeTotalScore(
|
|
2066
|
+
{ parsing: parsing.score, match: match?.score, recruiter: recruiter.score },
|
|
2067
|
+
cfg.weights.tiers
|
|
2068
|
+
);
|
|
2069
|
+
const capped = roleMismatch && totalScore > ROLE_MISMATCH_CAP;
|
|
2070
|
+
if (capped) totalScore = ROLE_MISMATCH_CAP;
|
|
2071
|
+
const rating = scoreToRating(totalScore, cfg.thresholds.rating);
|
|
2072
|
+
let summary = generateSummary(totalScore, rating, !!options.jobDescription, knockouts.length);
|
|
2073
|
+
if (capped) summary += " Score capped: resume role family does not match this posting.";
|
|
2074
|
+
return {
|
|
2075
|
+
score: totalScore,
|
|
2076
|
+
rating,
|
|
2077
|
+
tiers: match ? { parsing, match, recruiter } : { parsing, recruiter },
|
|
2078
|
+
knockouts,
|
|
2079
|
+
summary
|
|
2080
|
+
};
|
|
2081
|
+
}
|
|
2082
|
+
var ROLE_MISMATCH_CAP = 45;
|
|
2083
|
+
|
|
2084
|
+
export {
|
|
2085
|
+
loadConfig,
|
|
2086
|
+
analyzeAts
|
|
2087
|
+
};
|
|
2088
|
+
//# sourceMappingURL=chunk-C2JG5KF4.js.map
|