@pranavraut033/ats-checker 1.3.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { clamp, mergeKeywordRegistries, defaultKeywordRegistry, softwareEngineerProfile, buildCategoryIndex, deriveSkillAliases, normalizeWhitespace, splitLines, normalizeSkills, tokenize, unique, containsTableLikeStructure, normalizeForComparison, STOP_WORDS, normalizeSkill, countFrequencies, escapeRegExp } from './chunk-ZJ5E4H7Z.mjs';
2
- export { defaultKeywordRegistry, defaultProfiles, defaultSkillAliases } from './chunk-ZJ5E4H7Z.mjs';
1
+ import { ROLE_NOUNS, unique, clamp, mergeKeywordRegistries, defaultKeywordRegistry, softwareEngineerProfile, buildCategoryIndex, deriveSkillAliases, normalizeWhitespace, splitLines, detectFormatting, normalizeSkills, tokenize, normalizeForComparison, STOP_WORDS, normalizeSkill, countFrequencies, escapeRegExp, fuzzyEqual, stem } from './chunk-JAFFS7YZ.mjs';
2
+ export { defaultKeywordRegistry, defaultProfiles, defaultSkillAliases } from './chunk-JAFFS7YZ.mjs';
3
3
 
4
4
  // src/utils/languages.ts
5
5
  var KNOWN_LANGUAGES = [
@@ -64,11 +64,11 @@ var LEVEL_RANK = {
64
64
  muttersprache: 6,
65
65
  muttersprachler: 6,
66
66
  // French
67
- "d\xE9butant": 1,
68
- "\xE9l\xE9mentaire": 1,
69
- "limit\xE9": 2,
70
- "interm\xE9diaire": 3,
71
- "avanc\xE9": 4,
67
+ d\u00E9butant: 1,
68
+ \u00E9l\u00E9mentaire: 1,
69
+ limit\u00E9: 2,
70
+ interm\u00E9diaire: 3,
71
+ avanc\u00E9: 4,
72
72
  courant: 5,
73
73
  natif: 6,
74
74
  "langue maternelle": 6,
@@ -133,6 +133,67 @@ function diffLanguages(resumeLanguages, requiredLanguages) {
133
133
  return { matched, missing };
134
134
  }
135
135
 
136
+ // src/utils/titles.ts
137
+ var SENIORITY_SIGNALS = [
138
+ { seniority: "principal", pattern: /\bprincipal\b/i },
139
+ { seniority: "lead", pattern: /\b(lead|staff|head of|director)\b/i },
140
+ { seniority: "senior", pattern: /\b(senior|sr\.?)\b/i },
141
+ {
142
+ seniority: "junior",
143
+ pattern: /\b(junior|jr\.?|entry[\s-]?level|intern(?:ship)?|associate)\b/i
144
+ }
145
+ ];
146
+ var SENIORITY_RANK = {
147
+ junior: 0,
148
+ mid: 1,
149
+ senior: 2,
150
+ lead: 3,
151
+ principal: 4
152
+ };
153
+ function inferSeniority(titles) {
154
+ let best;
155
+ for (const title of titles) {
156
+ for (const { seniority, pattern } of SENIORITY_SIGNALS) {
157
+ if (pattern.test(title)) {
158
+ if (!best || SENIORITY_RANK[seniority] > SENIORITY_RANK[best]) {
159
+ best = seniority;
160
+ }
161
+ break;
162
+ }
163
+ }
164
+ }
165
+ return best;
166
+ }
167
+ function normalizeTitle(title) {
168
+ return title.toLowerCase().replace(/[.,/#!$%^&*;:{}=_`~()]/g, " ").replace(/\s+/g, " ").trim();
169
+ }
170
+ function titleTokens(title) {
171
+ const tokens = normalizeTitle(title).split(" ").filter((t) => t && !STOP_WORDS.has(t)).map((t) => stem(t));
172
+ return new Set(tokens);
173
+ }
174
+ function titleMatch(resumeTitles, jdRoleKeywords) {
175
+ const keywords = jdRoleKeywords.map((k) => stem(normalizeTitle(k))).filter(Boolean);
176
+ if (keywords.length === 0) {
177
+ return 0;
178
+ }
179
+ const resumeTokenSets = resumeTitles.map(titleTokens);
180
+ const roleNounStems = new Set(ROLE_NOUNS.map((n) => stem(n)));
181
+ let matched = 0;
182
+ for (const keyword of keywords) {
183
+ const isRoleNoun = roleNounStems.has(keyword);
184
+ const hit = resumeTokenSets.some((tokens) => {
185
+ if (tokens.has(keyword)) {
186
+ return true;
187
+ }
188
+ return isRoleNoun && [...tokens].some((t) => roleNounStems.has(t) && t === keyword);
189
+ });
190
+ if (hit) {
191
+ matched += 1;
192
+ }
193
+ }
194
+ return matched / keywords.length;
195
+ }
196
+
136
197
  // src/core/parser/jd.parser.ts
137
198
  var DEGREE_VARIANTS = [
138
199
  [/\b(?:bachelor(?:'s)?|b\.s\.?|bs\.?|bsc\.?|licence)\b/i, "bachelor"],
@@ -159,13 +220,49 @@ function extractPreferredSkills(lines) {
159
220
  }
160
221
  return preferred;
161
222
  }
223
+ var REQUIRED_HEADER_RE = /^(?:requirements?|must[\s-]?haves?|required\s+skills?|required\s+qualifications?|qualifications?|minimum\s+qualifications?)\s*:?\s*$/i;
224
+ var PREFERRED_HEADER_RE = /^(?:preferred(?:\s+skills?|\s+qualifications?)?|nice[\s-]?to[\s-]?haves?|bonus(?:\s+points?)?|pluses?)\s*:?\s*$/i;
225
+ 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;
226
+ function extractHeaderScopedSkills(lines) {
227
+ const required = [];
228
+ const preferred = [];
229
+ let scope = null;
230
+ for (const line of lines) {
231
+ if (REQUIRED_HEADER_RE.test(line)) {
232
+ scope = "required";
233
+ continue;
234
+ }
235
+ if (PREFERRED_HEADER_RE.test(line)) {
236
+ scope = "preferred";
237
+ continue;
238
+ }
239
+ if (OTHER_HEADER_RE.test(line)) {
240
+ scope = null;
241
+ continue;
242
+ }
243
+ if (scope === "required") {
244
+ required.push(...tokenize(line));
245
+ } else if (scope === "preferred") {
246
+ preferred.push(...tokenize(line));
247
+ }
248
+ }
249
+ return { required, preferred };
250
+ }
251
+ var ROLE_NOUN_RE = new RegExp(`(${ROLE_NOUNS.join("|")})`, "gi");
162
252
  function extractRoleKeywords(text) {
163
- const roleMatches = text.match(/(engineer|developer|manager|scientist|analyst|designer|architect|director|consultant|lead|vp)/gi) ?? [];
253
+ const roleMatches = text.match(ROLE_NOUN_RE) ?? [];
164
254
  const fallback = roleMatches.length === 0 ? [text.split(/\n/)[0] ?? ""] : [];
165
255
  return unique(tokenize([...roleMatches, ...fallback].join(" ")));
166
256
  }
257
+ function extractTitleContextLines(text) {
258
+ const lines = splitLines(text);
259
+ const roleLines = lines.filter((line) => line.match(ROLE_NOUN_RE) !== null);
260
+ return roleLines.length > 0 ? roleLines : lines.slice(0, 1);
261
+ }
167
262
  function extractMinExperience(text) {
168
- const match = text.match(/(\d{1,2})\+?\s*(?:years?|yrs\.?|jahre?|ans?|années?)/i);
263
+ const match = text.match(
264
+ /(\d{1,2})\+?\s*(?:years?|yrs\.?|jahre?|ans?|années?)/i
265
+ );
169
266
  if (!match) return void 0;
170
267
  const parsed = Number.parseInt(match[1], 10);
171
268
  return parsed <= 60 ? parsed : void 0;
@@ -191,6 +288,38 @@ function isLanguageRequired(lang, jobDescription) {
191
288
  return LANG_SECTION_RE.test(line) || LANG_REQUIREMENT_HINT_RE.test(line);
192
289
  });
193
290
  }
291
+ var YEARS_BEFORE_SKILL_RE = /(\d{1,2})\+?\s*years?\s*(?:of|in|with)?\s*((?:[a-z0-9][a-z0-9.#+\-/]*\s*){1,4})/gi;
292
+ var SKILL_BEFORE_YEARS_RE = /((?:[a-z0-9][a-z0-9.#+\-/]*[\s,]*){1,4})\(?(\d{1,2})\+?\s*years?\)?/gi;
293
+ function extractSkillExperienceRequirements(lines, skillVocab, aliases) {
294
+ const requirements = /* @__PURE__ */ new Map();
295
+ const record = (rawWord, years) => {
296
+ if (!Number.isFinite(years) || years <= 0 || years > 60) return;
297
+ const canonical = normalizeSkill(rawWord, aliases);
298
+ if (!skillVocab.has(rawWord.toLowerCase()) && !skillVocab.has(canonical))
299
+ return;
300
+ const existing = requirements.get(canonical);
301
+ if (existing === void 0 || years > existing) {
302
+ requirements.set(canonical, years);
303
+ }
304
+ };
305
+ for (const line of lines) {
306
+ for (const re of [YEARS_BEFORE_SKILL_RE, SKILL_BEFORE_YEARS_RE]) {
307
+ re.lastIndex = 0;
308
+ let match;
309
+ while (match = re.exec(line)) {
310
+ const years = Number.parseInt(
311
+ re === YEARS_BEFORE_SKILL_RE ? match[1] : match[2],
312
+ 10
313
+ );
314
+ const candidateWords = tokenize(
315
+ re === YEARS_BEFORE_SKILL_RE ? match[2] : match[1]
316
+ );
317
+ for (const word of candidateWords) record(word, years);
318
+ }
319
+ }
320
+ }
321
+ return [...requirements.entries()].map(([skill, years]) => ({ skill, years })).sort((a, b) => a.skill.localeCompare(b.skill));
322
+ }
194
323
  function extractDegreeLevels(text) {
195
324
  const found = /* @__PURE__ */ new Set();
196
325
  for (const [pattern, canonical] of DEGREE_VARIANTS) {
@@ -206,21 +335,47 @@ function parseJobDescription(jobDescription, config) {
206
335
  skillVocab.add(canonical.toLowerCase());
207
336
  for (const alias of aliases) skillVocab.add(alias.toLowerCase());
208
337
  }
209
- for (const s of config.profile?.mandatorySkills ?? []) skillVocab.add(s.toLowerCase());
210
- for (const s of config.profile?.optionalSkills ?? []) skillVocab.add(s.toLowerCase());
338
+ for (const s of config.profile?.mandatorySkills ?? [])
339
+ skillVocab.add(s.toLowerCase());
340
+ for (const s of config.profile?.optionalSkills ?? [])
341
+ skillVocab.add(s.toLowerCase());
211
342
  const isSkillLike = (t) => {
212
343
  if (skillVocab.has(t)) return true;
213
344
  if (/[.#+]/.test(t) && /[a-z]/.test(t)) return true;
214
- if (t.includes("/")) return t.split("/").some((p) => p.length >= 2 && !STOP_WORDS.has(p));
345
+ if (t.includes("/"))
346
+ return t.split("/").some((p) => p.length >= 2 && !STOP_WORDS.has(p));
215
347
  return false;
216
348
  };
217
- const requiredSkillsRaw = extractRequiredSkills(lines).filter(isSkillLike);
218
- const preferredSkillsRaw = extractPreferredSkills(lines).filter(isSkillLike);
219
- const requiredSkills = normalizeSkills(requiredSkillsRaw, config.skillAliases);
220
- const preferredSkills = normalizeSkills(preferredSkillsRaw, config.skillAliases);
349
+ const headerScoped = extractHeaderScopedSkills(lines);
350
+ const requiredSkillsRaw = unique([
351
+ ...extractRequiredSkills(lines),
352
+ ...headerScoped.required
353
+ ]).filter(isSkillLike);
354
+ const preferredSkillsRaw = unique([
355
+ ...extractPreferredSkills(lines),
356
+ ...headerScoped.preferred
357
+ ]).filter(isSkillLike);
358
+ const requiredSkills = normalizeSkills(
359
+ requiredSkillsRaw,
360
+ config.skillAliases
361
+ );
362
+ const preferredSkills = normalizeSkills(
363
+ preferredSkillsRaw,
364
+ config.skillAliases
365
+ );
221
366
  const bodyTokens = tokenize(normalizedText).filter(isSkillLike);
222
367
  const roleKeywords = extractRoleKeywords(jobDescription);
223
- const keywords = unique([...requiredSkills, ...preferredSkills, ...roleKeywords, ...bodyTokens]);
368
+ const keywords = unique([
369
+ ...requiredSkills,
370
+ ...preferredSkills,
371
+ ...roleKeywords,
372
+ ...bodyTokens
373
+ ]);
374
+ const skillExperienceRequirements = extractSkillExperienceRequirements(
375
+ lines,
376
+ skillVocab,
377
+ config.skillAliases
378
+ );
224
379
  return {
225
380
  raw: jobDescription,
226
381
  normalizedText,
@@ -229,13 +384,18 @@ function parseJobDescription(jobDescription, config) {
229
384
  roleKeywords,
230
385
  keywords,
231
386
  minExperienceYears: extractMinExperience(jobDescription),
387
+ skillExperienceRequirements,
232
388
  educationRequirements: extractDegreeLevels(jobDescription),
233
- keywordSurfaceForms: collectKeywordSurfaceForms(jobDescription, config.skillAliases),
389
+ keywordSurfaceForms: collectKeywordSurfaceForms(
390
+ jobDescription,
391
+ config.skillAliases
392
+ ),
234
393
  // A language only counts as required if its mention carries a requirement/level cue
235
394
  // or sits in a "Languages:" line — plain references ("our Berlin office") don't count.
236
395
  requiredLanguages: parseLanguageMentions(jobDescription).filter(
237
396
  (lang) => isLanguageRequired(lang, jobDescription)
238
- )
397
+ ),
398
+ seniority: inferSeniority(extractTitleContextLines(jobDescription))
239
399
  };
240
400
  }
241
401
 
@@ -333,7 +493,9 @@ function parseDateRange(text, referenceDate) {
333
493
  }
334
494
  const startToken = parseDateToken(rangeMatch[1]);
335
495
  const endRaw = rangeMatch[2];
336
- const isPresent = /present|current|now|aktuell|heute|actuellement|présent|actuel/i.test(endRaw);
496
+ const isPresent = /present|current|now|aktuell|heute|actuellement|présent|actuel/i.test(
497
+ endRaw
498
+ );
337
499
  const endToken = isPresent ? void 0 : parseDateToken(endRaw);
338
500
  if (!startToken) {
339
501
  return null;
@@ -381,27 +543,92 @@ function sumExperienceYears(ranges) {
381
543
  totalMonths += curEnd - curStart + 1;
382
544
  return Number((totalMonths / 12).toFixed(2));
383
545
  }
384
- const months = ranges.reduce((total, r) => total + (r.durationInMonths ?? 0), 0);
546
+ const months = ranges.reduce(
547
+ (total, r) => total + (r.durationInMonths ?? 0),
548
+ 0
549
+ );
385
550
  return Number((months / 12).toFixed(2));
386
551
  }
552
+ function detectEmploymentGaps(entries, minGapMonths = 3) {
553
+ const toIndex = (year, month) => year * 12 + month;
554
+ const intervals = entries.filter(
555
+ (e) => e.dates?.startYear !== void 0 && e.dates?.endYear !== void 0
556
+ ).map((e) => ({
557
+ s: toIndex(e.dates.startYear, e.dates.startMonth ?? 1),
558
+ e: toIndex(e.dates.endYear, e.dates.endMonth ?? 12),
559
+ label: e.title || e.company || "previous role"
560
+ })).sort((a, b) => a.s - b.s || b.e - a.e);
561
+ if (intervals.length < 2) {
562
+ return [];
563
+ }
564
+ const gaps = [];
565
+ let curEnd = intervals[0].e;
566
+ let curEndLabel = intervals[0].label;
567
+ for (let i = 1; i < intervals.length; i++) {
568
+ const interval = intervals[i];
569
+ if (interval.s <= curEnd + 1) {
570
+ if (interval.e > curEnd) {
571
+ curEnd = interval.e;
572
+ curEndLabel = interval.label;
573
+ }
574
+ continue;
575
+ }
576
+ const gapMonths = interval.s - curEnd - 1;
577
+ if (gapMonths >= minGapMonths) {
578
+ gaps.push({ afterRole: curEndLabel, months: gapMonths });
579
+ }
580
+ curEnd = interval.e;
581
+ curEndLabel = interval.label;
582
+ }
583
+ return gaps;
584
+ }
387
585
 
388
586
  // src/core/parser/resume.parser.ts
389
587
  var SECTION_ALIASES = {
390
- summary: ["summary", "profile", "about", "zusammenfassung", "profil", "r\xE9sum\xE9", "\xE0 propos"],
588
+ summary: [
589
+ "summary",
590
+ "profile",
591
+ "about",
592
+ "zusammenfassung",
593
+ "profil",
594
+ "r\xE9sum\xE9",
595
+ "\xE0 propos"
596
+ ],
391
597
  experience: [
392
598
  "experience",
393
599
  "work experience",
394
600
  "professional experience",
395
601
  "employment",
602
+ "work history",
603
+ "employment history",
396
604
  "erfahrung",
397
605
  "berufserfahrung",
398
606
  "exp\xE9rience",
399
607
  "exp\xE9rience professionnelle"
400
608
  ],
401
- skills: ["skills", "technical skills", "technologies", "f\xE4higkeiten", "kenntnisse", "comp\xE9tences"],
402
- education: ["education", "academics", "academic background", "ausbildung", "formation", "\xE9tudes"],
609
+ skills: [
610
+ "skills",
611
+ "technical skills",
612
+ "technologies",
613
+ "f\xE4higkeiten",
614
+ "kenntnisse",
615
+ "comp\xE9tences"
616
+ ],
617
+ education: [
618
+ "education",
619
+ "academics",
620
+ "academic background",
621
+ "ausbildung",
622
+ "formation",
623
+ "\xE9tudes"
624
+ ],
403
625
  projects: ["projects", "portfolio", "projekte", "projets"],
404
- certifications: ["certifications", "licenses", "zertifizierungen", "certifications professionnelles"]
626
+ certifications: [
627
+ "certifications",
628
+ "licenses",
629
+ "zertifizierungen",
630
+ "certifications professionnelles"
631
+ ]
405
632
  };
406
633
  var STRONG_VERBS = [
407
634
  "led",
@@ -424,28 +651,90 @@ var STRONG_VERBS = [
424
651
  "reduced",
425
652
  "increased"
426
653
  ];
427
- var WEAK_VERBS = ["worked", "helped", "performed", "responsible", "assisted", "participated", "involved"];
654
+ var WEAK_VERBS = [
655
+ "worked",
656
+ "helped",
657
+ "performed",
658
+ "responsible",
659
+ "assisted",
660
+ "participated",
661
+ "involved"
662
+ ];
663
+ var TITLE_SENIORITY_PREFIXES = [
664
+ "senior",
665
+ "lead",
666
+ "principal",
667
+ "staff",
668
+ "vp",
669
+ "director"
670
+ ];
671
+ var TITLE_MODIFIER_WORDS = [
672
+ "software",
673
+ "full\\s*stack",
674
+ "frontend",
675
+ "backend"
676
+ ];
677
+ var TITLE_PREFIX_WORDS = unique([
678
+ ...TITLE_SENIORITY_PREFIXES,
679
+ ...TITLE_MODIFIER_WORDS,
680
+ ...ROLE_NOUNS
681
+ ]);
682
+ var TITLE_RE = new RegExp(`^(${TITLE_PREFIX_WORDS.join("|")})[^,(-]*`, "i");
428
683
  var METRIC_RE = /\d|%|\$|\bk\+|\bm\+/i;
429
684
  function classifyAchievement(line) {
430
685
  const lower = line.toLowerCase();
431
686
  const hasMetric = METRIC_RE.test(line);
432
- const hasStrongVerb = STRONG_VERBS.some((verb) => new RegExp(`\\b${verb}\\b`).test(lower));
433
- const hasWeakVerb = WEAK_VERBS.some((verb) => new RegExp(`\\b${verb}\\b`).test(lower));
687
+ const hasStrongVerb = STRONG_VERBS.some(
688
+ (verb) => new RegExp(`\\b${verb}\\b`).test(lower)
689
+ );
690
+ const hasWeakVerb = WEAK_VERBS.some(
691
+ (verb) => new RegExp(`\\b${verb}\\b`).test(lower)
692
+ );
434
693
  if (hasStrongVerb && hasMetric) {
435
- return { text: line, strength: "strong", reason: "strong verb + quantified impact" };
694
+ return {
695
+ text: line,
696
+ strength: "strong",
697
+ reason: "strong verb + quantified impact"
698
+ };
436
699
  }
437
700
  if (hasWeakVerb) {
438
701
  return { text: line, strength: "weak", reason: "weak verb" };
439
702
  }
440
703
  return { text: line, strength: "weak", reason: "no quantified impact" };
441
704
  }
705
+ var HEADER_TAIL_RE = /^[\s:.\-–—,]*(?:\d{4}\s*(?:[-–—]|to)\s*(?:\d{4}|present|current|now)|\d{4})?[\s:.\-–—,]*$/i;
706
+ var MAX_HEADER_LINE_LENGTH = 60;
442
707
  function detectSection(line) {
443
- const normalized = line.trim().toLowerCase();
708
+ const trimmed = line.trim();
709
+ if (!trimmed || trimmed.length > MAX_HEADER_LINE_LENGTH) return null;
710
+ const normalized = trimmed.toLowerCase();
444
711
  for (const [section, aliases] of Object.entries(SECTION_ALIASES)) {
445
712
  for (const alias of aliases) {
446
713
  const safeAlias = escapeRegExp(alias);
447
- const headerPattern = new RegExp(`^${safeAlias}(\\s*:)?$`, "i");
448
- if (headerPattern.test(normalized)) {
714
+ const headerPattern = new RegExp(`^${safeAlias}(.*)$`, "i");
715
+ const match = normalized.match(headerPattern);
716
+ if (match && HEADER_TAIL_RE.test(match[1])) {
717
+ return section;
718
+ }
719
+ }
720
+ }
721
+ const coreLine = normalized.replace(/[\s:.\-–—,]+$/, "");
722
+ const lineWords = coreLine.split(/\s+/).filter(Boolean);
723
+ for (const [section, aliases] of Object.entries(SECTION_ALIASES)) {
724
+ for (const alias of aliases) {
725
+ const aliasWords = alias.split(/\s+/);
726
+ if (lineWords.length < aliasWords.length) continue;
727
+ const candidateWords = lineWords.slice(0, aliasWords.length);
728
+ const allFuzzy = aliasWords.every(
729
+ (aliasWord, i) => aliasWord !== candidateWords[i] && fuzzyEqual(stem(aliasWord), stem(candidateWords[i]))
730
+ );
731
+ if (!allFuzzy) continue;
732
+ const prefixPattern = new RegExp(
733
+ `^\\s*${candidateWords.map((w) => escapeRegExp(w)).join("\\s+")}(.*)$`,
734
+ "i"
735
+ );
736
+ const prefixMatch = normalized.match(prefixPattern);
737
+ if (prefixMatch && HEADER_TAIL_RE.test(prefixMatch[1])) {
449
738
  return section;
450
739
  }
451
740
  }
@@ -477,12 +766,42 @@ function extractSections(text) {
477
766
  flush();
478
767
  return { sections, detected: unique(detected) };
479
768
  }
480
- function parseSkills(sectionContent, aliases) {
769
+ function parseExplicitSkillList(sectionContent) {
481
770
  if (!sectionContent) return [];
482
771
  const hasBullets = /[•·‣▪○●◦]/.test(sectionContent);
483
772
  const normalized = hasBullets ? sectionContent.replace(/\n/g, " ") : sectionContent;
484
- const raw = normalized.split(/[,;\n]|[•·‣▪○●◦]/).map((skill) => skill.trim().replace(/^[-•·‣▪○●◦\s]+|[-•·‣▪○●◦\s]+$/g, "").trim()).filter(Boolean);
485
- return normalizeSkills(raw, aliases);
773
+ return normalized.split(/[,;\n]|[•·‣▪○●◦]/).map(
774
+ (skill) => skill.trim().replace(/^[-•·‣▪○●◦\s]+|[-•·‣▪○●◦\s]+$/g, "").trim()
775
+ ).filter(Boolean);
776
+ }
777
+ function extractKnownSkillsFromText(text, aliases) {
778
+ if (!text) return [];
779
+ const canonicalSet = new Set(
780
+ Object.keys(aliases).map((c) => c.toLowerCase())
781
+ );
782
+ const found = [];
783
+ for (const token of tokenize(text)) {
784
+ const canonical = normalizeSkill(token, aliases);
785
+ if (canonicalSet.has(canonical)) {
786
+ found.push(canonical);
787
+ }
788
+ }
789
+ return unique(found);
790
+ }
791
+ function parseSkills(sections, aliases) {
792
+ const explicit = normalizeSkills(
793
+ parseExplicitSkillList(sections.skills),
794
+ aliases
795
+ );
796
+ const fromExperience = extractKnownSkillsFromText(
797
+ sections.experience,
798
+ aliases
799
+ );
800
+ const fromSummary = extractKnownSkillsFromText(sections.summary, aliases);
801
+ return normalizeSkills(
802
+ unique([...explicit, ...fromExperience, ...fromSummary]),
803
+ aliases
804
+ );
486
805
  }
487
806
  function parseActionVerbs(text) {
488
807
  const words = tokenize(text);
@@ -491,7 +810,7 @@ function parseActionVerbs(text) {
491
810
  weak: WEAK_VERBS.filter((verb) => words.includes(verb))
492
811
  };
493
812
  }
494
- function parseExperience(sectionContent, referenceDate) {
813
+ function parseExperience(sectionContent, referenceDate, aliases) {
495
814
  if (!sectionContent) {
496
815
  return { entries: [], rangesInMonths: [], jobTitles: [], achievements: [] };
497
816
  }
@@ -502,23 +821,32 @@ function parseExperience(sectionContent, referenceDate) {
502
821
  const achievements = [];
503
822
  for (const line of lines) {
504
823
  const range = parseDateRange(line, referenceDate);
824
+ const titleMatch2 = line.match(TITLE_RE);
825
+ const title = titleMatch2 ? titleMatch2[0].trim() : void 0;
505
826
  if (range) {
506
- const previous = entries[entries.length - 1];
507
- if (previous && !previous.dates) {
508
- previous.dates = range;
827
+ if (title) {
828
+ jobTitles.push(title.toLowerCase());
829
+ entries.push({ title, dates: range, description: line, skills: [] });
509
830
  } else {
510
- entries.push({ dates: range });
831
+ const previous = entries[entries.length - 1];
832
+ if (previous && !previous.dates) {
833
+ previous.dates = range;
834
+ } else {
835
+ entries.push({ dates: range, skills: [] });
836
+ }
511
837
  }
512
838
  if (range.durationInMonths) {
513
839
  rangesInMonths.push(range.durationInMonths);
514
840
  }
515
841
  continue;
516
842
  }
517
- const titleMatch = line.match(/^(Senior|Lead|Principal|Staff|VP|Director|Consultant|Architect|Software|Full\s*Stack|Frontend|Backend|Engineer|Developer|Manager|Analyst)[^,-]*/i);
518
- if (titleMatch) {
519
- const title = titleMatch[0].trim();
843
+ if (title) {
520
844
  jobTitles.push(title.toLowerCase());
521
- const entry = { title, description: line };
845
+ const entry = {
846
+ title,
847
+ description: line,
848
+ skills: []
849
+ };
522
850
  entries.push(entry);
523
851
  achievements.push(classifyAchievement(line));
524
852
  continue;
@@ -529,7 +857,15 @@ function parseExperience(sectionContent, referenceDate) {
529
857
  }
530
858
  achievements.push(classifyAchievement(line));
531
859
  }
532
- return { entries, rangesInMonths, jobTitles: unique(jobTitles), achievements };
860
+ for (const entry of entries) {
861
+ entry.skills = extractKnownSkillsFromText(entry.description, aliases);
862
+ }
863
+ return {
864
+ entries,
865
+ rangesInMonths,
866
+ jobTitles: unique(jobTitles),
867
+ achievements
868
+ };
533
869
  }
534
870
  function parseEducation(sectionContent) {
535
871
  if (!sectionContent) return [];
@@ -538,12 +874,38 @@ function parseEducation(sectionContent) {
538
874
  function collectKeywords(text) {
539
875
  return unique(tokenize(text));
540
876
  }
877
+ var EMAIL_RE = /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/i;
878
+ var PHONE_RE = /\+?\(?\d{1,4}\)?(?:[\s.-]?\d{2,4}){2,4}/;
879
+ var LINKEDIN_RE = /(?:https?:\/\/)?(?:[a-z]{2,3}\.)?linkedin\.com\/(?:in|pub)\/[a-z0-9\-_%]+\/?/i;
880
+ var LOCATION_RE = /^[A-Z][A-Za-z.'-]+(?:\s[A-Z][A-Za-z.'-]+)*,\s*[A-Z][A-Za-z]{1,20}$/;
881
+ function extractLocation(text) {
882
+ const candidateLines = splitLines(text).slice(0, 8);
883
+ for (const line of candidateLines) {
884
+ if (EMAIL_RE.test(line) || /\d/.test(line)) continue;
885
+ if (LOCATION_RE.test(line)) {
886
+ return line;
887
+ }
888
+ }
889
+ return void 0;
890
+ }
891
+ function extractContact(text) {
892
+ const email = text.match(EMAIL_RE)?.[0];
893
+ const phone = text.match(PHONE_RE)?.[0]?.trim();
894
+ const linkedin = text.match(LINKEDIN_RE)?.[0];
895
+ const location = extractLocation(text);
896
+ if (!email && !phone && !linkedin && !location) return void 0;
897
+ return { email, phone, linkedin, location };
898
+ }
541
899
  function parseResume(resumeText, config) {
542
900
  const normalizedText = normalizeWhitespace(resumeText);
543
901
  const { sections, detected } = extractSections(resumeText);
544
- const skills = parseSkills(sections.skills, config.skillAliases);
902
+ const skills = parseSkills(sections, config.skillAliases);
545
903
  const actionVerbs = parseActionVerbs(normalizedText);
546
- const experienceData = parseExperience(sections.experience, config.referenceDate);
904
+ const experienceData = parseExperience(
905
+ sections.experience,
906
+ config.referenceDate,
907
+ config.skillAliases
908
+ );
547
909
  const educationEntries = parseEducation(sections.education);
548
910
  let totalExperienceYears = sumExperienceYears(
549
911
  experienceData.entries.map((entry) => entry.dates).filter((range) => Boolean(range))
@@ -556,7 +918,12 @@ function parseResume(resumeText, config) {
556
918
  totalExperienceYears = parsed <= 60 ? parsed : 0;
557
919
  }
558
920
  }
559
- const requiredSections = ["summary", "experience", "skills", "education"];
921
+ const requiredSections = [
922
+ "summary",
923
+ "experience",
924
+ "skills",
925
+ "education"
926
+ ];
560
927
  const warnings = [];
561
928
  const lineCount = splitLines(resumeText).length;
562
929
  if (resumeText.trim().length < 100) {
@@ -573,6 +940,12 @@ function parseResume(resumeText, config) {
573
940
  warnings.push(`${section} section not detected`);
574
941
  }
575
942
  }
943
+ const contact = extractContact(resumeText);
944
+ if (!contact?.email) {
945
+ warnings.push(
946
+ "No email address detected \u2014 most ATS require a parseable contact email"
947
+ );
948
+ }
576
949
  return {
577
950
  raw: resumeText,
578
951
  normalizedText,
@@ -588,6 +961,10 @@ function parseResume(resumeText, config) {
588
961
  totalExperienceYears,
589
962
  keywords: collectKeywords(normalizedText),
590
963
  languages: parseLanguageMentions(resumeText),
964
+ contact,
965
+ seniority: inferSeniority(experienceData.jobTitles),
966
+ employmentGaps: detectEmploymentGaps(experienceData.entries),
967
+ formatting: detectFormatting(resumeText),
591
968
  warnings
592
969
  };
593
970
  }
@@ -598,7 +975,12 @@ var RuleEngine = class {
598
975
  this.config = config;
599
976
  }
600
977
  applySectionPenalties(resume) {
601
- const requiredSections = ["summary", "experience", "skills", "education"];
978
+ const requiredSections = [
979
+ "summary",
980
+ "experience",
981
+ "skills",
982
+ "education"
983
+ ];
602
984
  let totalPenalty = 0;
603
985
  const warnings = [];
604
986
  const penaltyBySection = {
@@ -624,19 +1006,26 @@ var RuleEngine = class {
624
1006
  const sectionResult = this.applySectionPenalties(input.resume);
625
1007
  totalPenalty += sectionResult.totalPenalty;
626
1008
  warnings.push(...sectionResult.warnings);
627
- if (containsTableLikeStructure(input.resume.raw)) {
1009
+ if (input.resume.formatting.hasTables) {
628
1010
  totalPenalty += 8;
629
1011
  warnings.push("Detected table-like or columnar formatting (penalty 8)");
630
1012
  }
631
1013
  if (input.overusedKeywords && input.overusedKeywords.length > 0) {
632
1014
  const penalty = input.overusedKeywords.length * this.config.keywordDensity.overusePenalty;
633
1015
  totalPenalty += penalty;
634
- warnings.push(`Keyword stuffing detected for: ${input.overusedKeywords.join(", ")} (penalty ${penalty})`);
1016
+ warnings.push(
1017
+ `Keyword stuffing detected for: ${input.overusedKeywords.join(", ")} (penalty ${penalty})`
1018
+ );
635
1019
  }
636
1020
  if (input.resume.detectedSections.length < 3) {
637
1021
  totalPenalty += 5;
638
1022
  warnings.push("Few recognizable sections found (penalty 5)");
639
1023
  }
1024
+ if (!input.resume.contact?.email) {
1025
+ const penalty = this.config.sectionPenalties.missingContact;
1026
+ totalPenalty += penalty;
1027
+ warnings.push(`Missing contact email (penalty ${penalty})`);
1028
+ }
640
1029
  return { totalPenalty, warnings };
641
1030
  }
642
1031
  evaluate(input) {
@@ -667,9 +1056,31 @@ var RuleEngine = class {
667
1056
  // src/core/scoring/scorer.ts
668
1057
  var REQUIRED_SKILL_WEIGHT = 0.7;
669
1058
  var OPTIONAL_SKILL_WEIGHT = 0.3;
670
- var EXPERIENCE_YEARS_WEIGHT = 0.75;
671
- var EXPERIENCE_ROLE_WEIGHT = 0.25;
672
- var ALL_CATEGORIES = ["technical", "tool", "concept", "soft", "marketing", "domain"];
1059
+ var EXPERIENCE_YEARS_WEIGHT = 0.65;
1060
+ var EXPERIENCE_ROLE_WEIGHT = 0.2;
1061
+ var EXPERIENCE_SENIORITY_WEIGHT = 0.15;
1062
+ var ALL_CATEGORIES = [
1063
+ "technical",
1064
+ "tool",
1065
+ "concept",
1066
+ "soft",
1067
+ "marketing",
1068
+ "domain"
1069
+ ];
1070
+ var SENIORITY_RANK2 = {
1071
+ junior: 0,
1072
+ mid: 1,
1073
+ senior: 2,
1074
+ lead: 3,
1075
+ principal: 4
1076
+ };
1077
+ var DEGREE_RANK = {
1078
+ associate: 1,
1079
+ bachelor: 2,
1080
+ master: 3,
1081
+ mba: 3,
1082
+ phd: 4
1083
+ };
673
1084
  function emptyCategoryBuckets() {
674
1085
  const buckets = {};
675
1086
  for (const category of ALL_CATEGORIES) {
@@ -677,18 +1088,32 @@ function emptyCategoryBuckets() {
677
1088
  }
678
1089
  return buckets;
679
1090
  }
680
- function scoreSkills(resume, job, config) {
1091
+ function scoreSkills(resume, job, config, matchOptions) {
681
1092
  const profileRequired = config.profile?.mandatorySkills ?? [];
682
1093
  const profileOptional = config.profile?.optionalSkills ?? [];
683
1094
  const required = new Set(
684
- normalizeSkills([...job.requiredSkills, ...profileRequired], config.skillAliases)
1095
+ normalizeSkills(
1096
+ [...job.requiredSkills, ...profileRequired],
1097
+ config.skillAliases,
1098
+ matchOptions
1099
+ )
685
1100
  );
686
1101
  const optional = new Set(
687
- normalizeSkills([...job.preferredSkills, ...profileOptional], config.skillAliases)
1102
+ normalizeSkills(
1103
+ [...job.preferredSkills, ...profileOptional],
1104
+ config.skillAliases,
1105
+ matchOptions
1106
+ )
1107
+ );
1108
+ const resumeSkills = new Set(
1109
+ normalizeSkills(resume.skills, config.skillAliases, matchOptions)
1110
+ );
1111
+ const matchedRequired = [...required].filter(
1112
+ (skill) => resumeSkills.has(skill)
1113
+ );
1114
+ const matchedOptional = [...optional].filter(
1115
+ (skill) => resumeSkills.has(skill)
688
1116
  );
689
- const resumeSkills = new Set(normalizeSkills(resume.skills, config.skillAliases));
690
- const matchedRequired = [...required].filter((skill) => resumeSkills.has(skill));
691
- const matchedOptional = [...optional].filter((skill) => resumeSkills.has(skill));
692
1117
  const requiredCoverage = required.size === 0 ? 1 : matchedRequired.length / required.size;
693
1118
  const optionalCoverage = optional.size === 0 ? 1 : matchedOptional.length / optional.size;
694
1119
  const score = clamp(
@@ -700,29 +1125,45 @@ function scoreSkills(resume, job, config) {
700
1125
  const missing = [...required].filter((skill) => !resumeSkills.has(skill)).sort();
701
1126
  return { score, matched, missing };
702
1127
  }
1128
+ function computeSeniorityMatch(resume, job) {
1129
+ const resumeSeniority = resume.seniority;
1130
+ const required = job.seniority;
1131
+ const met = !resumeSeniority || !required || SENIORITY_RANK2[resumeSeniority] >= SENIORITY_RANK2[required];
1132
+ return { resume: resumeSeniority, required, met };
1133
+ }
703
1134
  function scoreExperience(resume, job, config) {
1135
+ const seniorityMatch = computeSeniorityMatch(resume, job);
704
1136
  const requiredYears = job.minExperienceYears ?? config.profile?.minExperience ?? 0;
705
1137
  if (!requiredYears) {
706
- return { score: 100, missingYears: 0 };
1138
+ return { score: 100, missingYears: 0, seniorityMatch };
707
1139
  }
708
1140
  const yearCoverage = clamp(resume.totalExperienceYears / requiredYears, 0, 2);
709
1141
  const yearsComponent = clamp(yearCoverage, 0, 1) * EXPERIENCE_YEARS_WEIGHT;
710
- const jobRoleSet = new Set(job.roleKeywords.map((value) => value.toLowerCase()));
711
- const titleMatches = resume.jobTitles.filter((title) => jobRoleSet.has(title.toLowerCase()));
712
- const titleCoverage = jobRoleSet.size === 0 ? 1 : titleMatches.length / jobRoleSet.size;
1142
+ const titleCoverage = job.roleKeywords.length === 0 ? 1 : titleMatch(resume.jobTitles, job.roleKeywords);
713
1143
  const roleComponent = clamp(titleCoverage, 0, 1) * EXPERIENCE_ROLE_WEIGHT;
714
- const score = clamp((yearsComponent + roleComponent) * 100, 0, 100);
1144
+ const seniorityComponent = (seniorityMatch.met ? 1 : 0) * EXPERIENCE_SENIORITY_WEIGHT;
1145
+ const score = clamp(
1146
+ (yearsComponent + roleComponent + seniorityComponent) * 100,
1147
+ 0,
1148
+ 100
1149
+ );
715
1150
  const missingYears = Math.max(requiredYears - resume.totalExperienceYears, 0);
716
- return { score, missingYears: Number(missingYears.toFixed(2)) };
1151
+ return {
1152
+ score,
1153
+ missingYears: Number(missingYears.toFixed(2)),
1154
+ seniorityMatch
1155
+ };
717
1156
  }
718
1157
  function keywordWeightOf(keyword, requiredSet, preferredSet, jdFrequencies) {
719
1158
  const base = requiredSet.has(keyword) ? 3 : preferredSet.has(keyword) ? 2 : 1;
720
1159
  const freqBonus = Math.min((jdFrequencies[keyword] ?? 1) - 1, 3) * 0.25;
721
1160
  return base + freqBonus;
722
1161
  }
723
- function scoreKeywords(resume, job, config) {
1162
+ function scoreKeywords(resume, job, config, matchOptions) {
724
1163
  const jobKeywordSet = new Set(
725
- job.keywords.map((k) => normalizeSkill(k, config.skillAliases))
1164
+ job.keywords.map(
1165
+ (k) => normalizeSkill(k, config.skillAliases, matchOptions)
1166
+ )
726
1167
  );
727
1168
  if (jobKeywordSet.size === 0) {
728
1169
  return {
@@ -735,20 +1176,32 @@ function scoreKeywords(resume, job, config) {
735
1176
  };
736
1177
  }
737
1178
  const resumeTokens = tokenize(resume.normalizedText).map(
738
- (t) => normalizeSkill(t, config.skillAliases)
1179
+ (t) => normalizeSkill(t, config.skillAliases, matchOptions)
739
1180
  );
740
1181
  const resumeTokenSet = new Set(resumeTokens);
741
1182
  const resumeFrequencies = countFrequencies(resumeTokens);
742
1183
  const requiredSet = new Set(job.requiredSkills);
743
1184
  const preferredSet = new Set(job.preferredSkills);
744
1185
  const jdFrequencies = countFrequencies(
745
- tokenize(job.normalizedText).map((t) => normalizeSkill(t, config.skillAliases))
1186
+ tokenize(job.normalizedText).map(
1187
+ (t) => normalizeSkill(t, config.skillAliases, matchOptions)
1188
+ )
746
1189
  );
747
1190
  const weightOf = (keyword) => keywordWeightOf(keyword, requiredSet, preferredSet, jdFrequencies);
748
- const matchedKeywords = [...jobKeywordSet].filter((keyword) => resumeTokenSet.has(keyword));
749
- const missingKeywords = [...jobKeywordSet].filter((keyword) => !resumeTokenSet.has(keyword));
750
- const totalWeight = [...jobKeywordSet].reduce((sum, keyword) => sum + weightOf(keyword), 0);
751
- const matchedWeight = matchedKeywords.reduce((sum, keyword) => sum + weightOf(keyword), 0);
1191
+ const matchedKeywords = [...jobKeywordSet].filter(
1192
+ (keyword) => resumeTokenSet.has(keyword)
1193
+ );
1194
+ const missingKeywords = [...jobKeywordSet].filter(
1195
+ (keyword) => !resumeTokenSet.has(keyword)
1196
+ );
1197
+ const totalWeight = [...jobKeywordSet].reduce(
1198
+ (sum, keyword) => sum + weightOf(keyword),
1199
+ 0
1200
+ );
1201
+ const matchedWeight = matchedKeywords.reduce(
1202
+ (sum, keyword) => sum + weightOf(keyword),
1203
+ 0
1204
+ );
752
1205
  const score = clamp(matchedWeight / totalWeight * 100, 0, 100);
753
1206
  const totalTokens = resumeTokens.length || 1;
754
1207
  const overusedKeywords = matchedKeywords.filter((keyword) => {
@@ -785,38 +1238,158 @@ function scoreKeywords(resume, job, config) {
785
1238
  keywordWeights
786
1239
  };
787
1240
  }
1241
+ function computePerSkillExperience(resume, config, matchOptions) {
1242
+ const bySkill = /* @__PURE__ */ new Map();
1243
+ for (const entry of resume.experience) {
1244
+ if (!entry.dates || entry.skills.length === 0) continue;
1245
+ const skillsInRole = normalizeSkills(
1246
+ entry.skills,
1247
+ config.skillAliases,
1248
+ matchOptions
1249
+ );
1250
+ for (const skill of skillsInRole) {
1251
+ const list = bySkill.get(skill) ?? [];
1252
+ list.push(entry.dates);
1253
+ bySkill.set(skill, list);
1254
+ }
1255
+ }
1256
+ return [...bySkill.entries()].map(([skill, dates]) => ({ skill, years: sumExperienceYears(dates) })).sort((a, b) => a.skill.localeCompare(b.skill));
1257
+ }
1258
+ function computeSkillExperienceGaps(resume, job, config, matchOptions, perSkillExperience) {
1259
+ if (job.skillExperienceRequirements.length === 0) return [];
1260
+ const resumeSkills = new Set(
1261
+ normalizeSkills(resume.skills, config.skillAliases, matchOptions)
1262
+ );
1263
+ const yearsBySkill = new Map(
1264
+ perSkillExperience.map((p) => [p.skill, p.years])
1265
+ );
1266
+ const gaps = [];
1267
+ for (const { skill, years } of job.skillExperienceRequirements) {
1268
+ if (!resumeSkills.has(skill)) continue;
1269
+ const resumeYears = yearsBySkill.get(skill) ?? 0;
1270
+ if (resumeYears < years) {
1271
+ gaps.push({ skill, requiredYears: years, resumeYears });
1272
+ }
1273
+ }
1274
+ return gaps.sort((a, b) => a.skill.localeCompare(b.skill));
1275
+ }
788
1276
  function scoreEducation(resume, job) {
789
1277
  if (job.educationRequirements.length === 0) {
790
1278
  return 100;
791
1279
  }
792
- const resumeDegreeLevels = extractDegreeLevels(resume.educationEntries.join(" "));
793
- const matched = job.educationRequirements.filter(
794
- (requirement) => resumeDegreeLevels.includes(requirement)
1280
+ const resumeDegreeLevels = extractDegreeLevels(
1281
+ resume.educationEntries.join(" ")
795
1282
  );
796
- if (matched.length === 0) {
1283
+ if (resumeDegreeLevels.length === 0) {
797
1284
  return 0;
798
1285
  }
799
- return clamp(matched.length / job.educationRequirements.length * 100, 0, 100);
1286
+ const resumeMaxRank = Math.max(
1287
+ ...resumeDegreeLevels.map((d) => DEGREE_RANK[d] ?? 0)
1288
+ );
1289
+ const perRequirementCredit = job.educationRequirements.map(
1290
+ (requirement) => {
1291
+ if (resumeDegreeLevels.includes(requirement)) return 1;
1292
+ const requiredRank = DEGREE_RANK[requirement] ?? 0;
1293
+ const diff = resumeMaxRank - requiredRank;
1294
+ if (diff >= 1) return 0.85;
1295
+ if (diff === 0) return 0.6;
1296
+ if (diff === -1) return 0.35;
1297
+ return 0;
1298
+ }
1299
+ );
1300
+ const avgCredit = perRequirementCredit.reduce((sum, credit) => sum + credit, 0) / perRequirementCredit.length;
1301
+ return clamp(avgCredit * 100, 0, 100);
1302
+ }
1303
+ function scoreParseability(formatting, contact, detectedSections) {
1304
+ let score = 100;
1305
+ const deductions = [];
1306
+ const deduct = (points, reason) => {
1307
+ score -= points;
1308
+ deductions.push({ reason, points });
1309
+ };
1310
+ if (formatting.hasTables) {
1311
+ deduct(
1312
+ 20,
1313
+ "Table or columnar formatting detected \u2014 ATS parsers often misalign fields extracted from tables"
1314
+ );
1315
+ }
1316
+ if (formatting.hasMultiColumn) {
1317
+ deduct(
1318
+ 20,
1319
+ "Multi-column layout detected \u2014 text may extract out of reading order in real ATS systems"
1320
+ );
1321
+ }
1322
+ if (formatting.hasSpecialChars) {
1323
+ deduct(
1324
+ 10,
1325
+ "Special or control characters detected \u2014 likely icon fonts or a lossy text extraction"
1326
+ );
1327
+ }
1328
+ if (formatting.nonStandardBullets) {
1329
+ deduct(
1330
+ 8,
1331
+ "Non-standard bullet characters detected \u2014 some ATS parsers won't recognize them as list items"
1332
+ );
1333
+ }
1334
+ if (formatting.likelyScanned) {
1335
+ deduct(
1336
+ 30,
1337
+ "Resume text looks scanned or image-based with little extractable text"
1338
+ );
1339
+ }
1340
+ if (!contact?.email || !formatting.contactParseable) {
1341
+ deduct(15, "No parseable contact email detected");
1342
+ }
1343
+ if (detectedSections.length < 3) {
1344
+ deduct(10, "Fewer than 3 standard resume sections were detected");
1345
+ }
1346
+ return {
1347
+ score: clamp(score, 0, 100),
1348
+ report: {
1349
+ ...formatting,
1350
+ detectedSectionCount: detectedSections.length,
1351
+ deductions
1352
+ }
1353
+ };
800
1354
  }
801
1355
  function calculateScore(resume, job, config) {
802
- const skillsResult = scoreSkills(resume, job, config);
1356
+ const matchOptions = {
1357
+ fuzzy: config.matching.fuzzy,
1358
+ maxDistance: config.matching.threshold
1359
+ };
1360
+ const skillsResult = scoreSkills(resume, job, config, matchOptions);
803
1361
  const experienceResult = scoreExperience(resume, job, config);
804
- const keywordResult = scoreKeywords(resume, job, config);
1362
+ const keywordResult = scoreKeywords(resume, job, config, matchOptions);
805
1363
  const educationScore = scoreEducation(resume, job);
1364
+ const parseabilityResult = scoreParseability(
1365
+ resume.formatting,
1366
+ resume.contact,
1367
+ resume.detectedSections
1368
+ );
806
1369
  const breakdown = {
807
1370
  skills: skillsResult.score,
808
1371
  experience: experienceResult.score,
809
1372
  keywords: keywordResult.score,
1373
+ parseability: parseabilityResult.score,
810
1374
  education: educationScore
811
1375
  };
812
- const weightedScore = breakdown.skills * config.weights.skills + breakdown.experience * config.weights.experience + breakdown.keywords * config.weights.keywords + breakdown.education * config.weights.education;
1376
+ 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;
813
1377
  const achievementStrength = {
814
1378
  strong: resume.achievements.filter((a) => a.strength === "strong").length,
815
1379
  weak: resume.achievements.filter((a) => a.strength === "weak").length
816
1380
  };
817
- const { matched: matchedLanguages, missing: missingLanguages } = diffLanguages(
818
- resume.languages,
819
- job.requiredLanguages
1381
+ const { matched: matchedLanguages, missing: missingLanguages } = diffLanguages(resume.languages, job.requiredLanguages);
1382
+ const perSkillExperience = computePerSkillExperience(
1383
+ resume,
1384
+ config,
1385
+ matchOptions
1386
+ );
1387
+ const skillExperienceGaps = computeSkillExperienceGaps(
1388
+ resume,
1389
+ job,
1390
+ config,
1391
+ matchOptions,
1392
+ perSkillExperience
820
1393
  );
821
1394
  return {
822
1395
  score: clamp(Number(weightedScore.toFixed(2)), 0, 100),
@@ -831,6 +1404,7 @@ function calculateScore(resume, job, config) {
831
1404
  achievementStrength,
832
1405
  matchedLanguages,
833
1406
  missingLanguages,
1407
+ skillExperienceGaps,
834
1408
  suggestions: [],
835
1409
  warnings: [],
836
1410
  // detectedSections / parsedExperienceYears / experienceGap / experienceEntries: filled by index.ts
@@ -839,16 +1413,21 @@ function calculateScore(resume, job, config) {
839
1413
  parsedExperienceYears: 0,
840
1414
  experienceEntries: [],
841
1415
  missingExperienceYears: experienceResult.missingYears,
842
- educationScore
1416
+ educationScore,
1417
+ parseabilityReport: parseabilityResult.report,
1418
+ employmentGaps: resume.employmentGaps,
1419
+ seniorityMatch: experienceResult.seniorityMatch,
1420
+ perSkillExperience
843
1421
  };
844
1422
  }
845
1423
 
846
1424
  // src/core/scoring/weights.ts
847
1425
  var DEFAULT_WEIGHTS = {
848
- skills: 0.3,
849
- experience: 0.3,
1426
+ skills: 0.25,
1427
+ experience: 0.2,
850
1428
  keywords: 0.25,
851
- education: 0.15
1429
+ parseability: 0.2,
1430
+ education: 0.1
852
1431
  };
853
1432
  var DEFAULT_KEYWORD_DENSITY = {
854
1433
  min: 25e-4,
@@ -859,16 +1438,20 @@ var DEFAULT_SECTION_PENALTIES = {
859
1438
  missingSummary: 4,
860
1439
  missingExperience: 10,
861
1440
  missingSkills: 8,
862
- missingEducation: 6
1441
+ missingEducation: 6,
1442
+ // Raised from 0 (warning-only) in v1 — a real ATS treats an unparseable contact email as a
1443
+ // near-knockout (no way to reach the candidate at all), so this now actually moves the score.
1444
+ missingContact: 12
863
1445
  };
864
1446
  function normalizeWeights(weights) {
865
- const total = weights.skills + weights.experience + weights.keywords + weights.education;
1447
+ const total = weights.skills + weights.experience + weights.keywords + weights.parseability + weights.education;
866
1448
  if (total === 0) {
867
- const equal = 1 / 4;
1449
+ const equal = 1 / 5;
868
1450
  return {
869
1451
  skills: equal,
870
1452
  experience: equal,
871
1453
  keywords: equal,
1454
+ parseability: equal,
872
1455
  education: equal,
873
1456
  normalizedTotal: 1
874
1457
  };
@@ -877,6 +1460,7 @@ function normalizeWeights(weights) {
877
1460
  skills: weights.skills / total,
878
1461
  experience: weights.experience / total,
879
1462
  keywords: weights.keywords / total,
1463
+ parseability: weights.parseability / total,
880
1464
  education: weights.education / total,
881
1465
  normalizedTotal: 1
882
1466
  };
@@ -886,12 +1470,19 @@ function resolveConfig(config = {}) {
886
1470
  skills: config.weights?.skills ?? DEFAULT_WEIGHTS.skills,
887
1471
  experience: config.weights?.experience ?? DEFAULT_WEIGHTS.experience,
888
1472
  keywords: config.weights?.keywords ?? DEFAULT_WEIGHTS.keywords,
1473
+ parseability: config.weights?.parseability ?? DEFAULT_WEIGHTS.parseability,
889
1474
  education: config.weights?.education ?? DEFAULT_WEIGHTS.education
890
1475
  };
891
- const keywordRegistry = mergeKeywordRegistries(defaultKeywordRegistry, config.keywordRegistry ?? []);
1476
+ const keywordRegistry = mergeKeywordRegistries(
1477
+ defaultKeywordRegistry,
1478
+ config.keywordRegistry ?? []
1479
+ );
892
1480
  const resolved = {
893
1481
  weights: normalizeWeights(weights),
894
- skillAliases: { ...deriveSkillAliases(keywordRegistry), ...config.skillAliases ?? {} },
1482
+ skillAliases: {
1483
+ ...deriveSkillAliases(keywordRegistry),
1484
+ ...config.skillAliases ?? {}
1485
+ },
895
1486
  keywordRegistry,
896
1487
  categoryIndex: buildCategoryIndex(keywordRegistry),
897
1488
  profile: config.profile ?? softwareEngineerProfile,
@@ -902,6 +1493,13 @@ function resolveConfig(config = {}) {
902
1493
  ...config.sectionPenalties ?? {}
903
1494
  },
904
1495
  allowPartialMatches: config.allowPartialMatches ?? true,
1496
+ // Fuzzy/stem matching is on by default in v2 (typos, word-form variants like "developing"
1497
+ // vs "develop", "ReactJS" vs "react") — callers who want v1's exact-match-only behavior can
1498
+ // override via config.matching = { fuzzy: false }.
1499
+ matching: {
1500
+ fuzzy: config.matching?.fuzzy ?? true,
1501
+ threshold: config.matching?.threshold
1502
+ },
905
1503
  referenceDate: config.referenceDate ? new Date(config.referenceDate) : void 0
906
1504
  };
907
1505
  return resolved;
@@ -914,13 +1512,17 @@ function formatList(values, max = 6) {
914
1512
  return trimmed.join(", ") + (uniqueValues.length > max ? "..." : "");
915
1513
  }
916
1514
  function buildAliasReplacementSuggestions(resume, job, config) {
917
- const jobKeywordSet = new Set(job.keywords.map((k) => normalizeSkill(k, config.skillAliases)));
1515
+ const jobKeywordSet = new Set(
1516
+ job.keywords.map((k) => normalizeSkill(k, config.skillAliases))
1517
+ );
918
1518
  const replacements = [];
919
1519
  for (const token of unique(tokenize(resume.normalizedText))) {
920
1520
  const canonical = normalizeSkill(token, config.skillAliases);
921
1521
  const jdSurface = job.keywordSurfaceForms[canonical];
922
1522
  if (jdSurface && jobKeywordSet.has(canonical) && jdSurface.toLowerCase() !== token.toLowerCase()) {
923
- replacements.push(`Replace "${token}" with "${jdSurface}" to match the job description's wording.`);
1523
+ replacements.push(
1524
+ `Replace "${token}" with "${jdSurface}" to match the job description's wording.`
1525
+ );
924
1526
  }
925
1527
  }
926
1528
  return unique(replacements).slice(0, 5);
@@ -928,7 +1530,10 @@ function buildAliasReplacementSuggestions(resume, job, config) {
928
1530
  var SuggestionEngine = class {
929
1531
  generate(input) {
930
1532
  const suggestions = [];
931
- const warnings = [...input.ruleWarnings, ...input.resume.warnings];
1533
+ const warnings = [
1534
+ ...input.ruleWarnings,
1535
+ ...input.resume.warnings
1536
+ ];
932
1537
  if (input.score.missingSkills.length > 0) {
933
1538
  suggestions.push(
934
1539
  `Highlight these required skills: ${formatList(input.score.missingSkills)}`
@@ -939,7 +1544,9 @@ var SuggestionEngine = class {
939
1544
  `Incorporate job-specific keywords: ${formatList(input.score.missingKeywords)}`
940
1545
  );
941
1546
  }
942
- suggestions.push(...buildAliasReplacementSuggestions(input.resume, input.job, input.config));
1547
+ suggestions.push(
1548
+ ...buildAliasReplacementSuggestions(input.resume, input.job, input.config)
1549
+ );
943
1550
  if (input.score.overusedKeywords.length > 0) {
944
1551
  suggestions.push(
945
1552
  `Avoid keyword stuffing for: ${formatList(input.score.overusedKeywords)}`
@@ -950,7 +1557,7 @@ var SuggestionEngine = class {
950
1557
  `Clarify at least ${input.job.minExperienceYears ?? input.score.missingExperienceYears} years of relevant experience with quantified achievements.`
951
1558
  );
952
1559
  }
953
- if (input.job.educationRequirements.length > 0 && input.score.educationScore === 0) {
1560
+ if (input.job.educationRequirements.length > 0 && input.score.educationScore < 60) {
954
1561
  suggestions.push(
955
1562
  `State your education credentials matching: ${formatList(input.job.educationRequirements)}`
956
1563
  );
@@ -969,7 +1576,9 @@ var SuggestionEngine = class {
969
1576
  const formatted = input.score.missingLanguages.map((l) => l.level ? `${l.name} (${l.level})` : l.name).join(", ");
970
1577
  suggestions.push(`Mention your proficiency in: ${formatted}`);
971
1578
  }
972
- const weakAchievement = input.resume.achievements.find((a) => a.strength === "weak");
1579
+ const weakAchievement = input.resume.achievements.find(
1580
+ (a) => a.strength === "weak"
1581
+ );
973
1582
  if (weakAchievement) {
974
1583
  suggestions.push(
975
1584
  `Strengthen "${weakAchievement.text}" \u2014 add scope/metrics, e.g. "Built and maintained scalable services handling 500k+ requests/day."`
@@ -980,6 +1589,34 @@ var SuggestionEngine = class {
980
1589
  "Your resume may use a multi-column layout. Export as a single-column PDF or paste plain text \u2014 most ATS systems and this parser work best with a linear layout."
981
1590
  );
982
1591
  }
1592
+ if (!input.resume.contact?.email) {
1593
+ suggestions.push(
1594
+ "Add a clearly formatted email address near the top of your resume so ATS and recruiters can contact you."
1595
+ );
1596
+ }
1597
+ for (const gap of input.score.skillExperienceGaps) {
1598
+ suggestions.push(
1599
+ `The role asks for ${gap.requiredYears}+ years of ${gap.skill}; make that duration explicit in your experience section.`
1600
+ );
1601
+ }
1602
+ if (input.score.breakdown.parseability < 80) {
1603
+ for (const deduction of input.score.parseabilityReport.deductions) {
1604
+ suggestions.push(`Improve resume parseability: ${deduction.reason}.`);
1605
+ }
1606
+ }
1607
+ const GAP_THRESHOLD_MONTHS = 6;
1608
+ for (const gap of input.score.employmentGaps) {
1609
+ if (gap.months >= GAP_THRESHOLD_MONTHS) {
1610
+ suggestions.push(
1611
+ `Address the ${gap.months}-month employment gap after "${gap.afterRole}" \u2014 a brief explanation (upskilling, caregiving, freelance work, etc.) reassures recruiters.`
1612
+ );
1613
+ }
1614
+ }
1615
+ if (!input.score.seniorityMatch.met) {
1616
+ suggestions.push(
1617
+ `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.`
1618
+ );
1619
+ }
983
1620
  return { suggestions, warnings };
984
1621
  }
985
1622
  };
@@ -1027,7 +1664,10 @@ var LLMBudgetManager = class {
1027
1664
  callsUsed: this.callCount,
1028
1665
  callsRemaining: Math.max(0, this.limits.maxCalls - this.callCount),
1029
1666
  tokensUsed: this.totalTokensUsed,
1030
- tokensRemaining: Math.max(0, this.limits.maxTotalTokens - this.totalTokensUsed),
1667
+ tokensRemaining: Math.max(
1668
+ 0,
1669
+ this.limits.maxTotalTokens - this.totalTokensUsed
1670
+ ),
1031
1671
  totalCalls: this.limits.maxCalls,
1032
1672
  totalTokens: this.limits.maxTotalTokens
1033
1673
  };
@@ -1049,7 +1689,8 @@ var LLMBudgetManager = class {
1049
1689
 
1050
1690
  // src/llm/validation.ts
1051
1691
  function validateJsonSchema(data, schema) {
1052
- if (schema.type !== "object" || typeof data !== "object" || data === null) return false;
1692
+ if (schema.type !== "object" || typeof data !== "object" || data === null)
1693
+ return false;
1053
1694
  const obj = data;
1054
1695
  if (schema.required) {
1055
1696
  for (const r of schema.required) {
@@ -1079,9 +1720,12 @@ function validateJsonSchema(data, schema) {
1079
1720
  if (items && items.type && Array.isArray(value)) {
1080
1721
  const itemType = items.type;
1081
1722
  for (const item of value) {
1082
- if (itemType === "string" && typeof item !== "string") return false;
1083
- if (itemType === "number" && typeof item !== "number") return false;
1084
- if (itemType === "object" && (typeof item !== "object" || item === null)) return false;
1723
+ if (itemType === "string" && typeof item !== "string")
1724
+ return false;
1725
+ if (itemType === "number" && typeof item !== "number")
1726
+ return false;
1727
+ if (itemType === "object" && (typeof item !== "object" || item === null))
1728
+ return false;
1085
1729
  }
1086
1730
  }
1087
1731
  break;
@@ -1108,24 +1752,36 @@ var LLMManager = class {
1108
1752
  */
1109
1753
  async callLLM(systemPrompt, userPrompt, schema, options = {}) {
1110
1754
  const onUnhandled = (reason) => {
1111
- this.warnings.push(`Unhandled rejection during LLM call: ${String(reason)}`);
1755
+ this.warnings.push(
1756
+ `Unhandled rejection during LLM call: ${String(reason)}`
1757
+ );
1112
1758
  };
1113
1759
  process.on("unhandledRejection", onUnhandled);
1114
1760
  let clientPromise = void 0;
1115
1761
  try {
1116
- const estimatedTokens = this.estimateTokens(systemPrompt, userPrompt, options.requestedTokens);
1762
+ const estimatedTokens = this.estimateTokens(
1763
+ systemPrompt,
1764
+ userPrompt,
1765
+ options.requestedTokens
1766
+ );
1117
1767
  try {
1118
1768
  this.budgetManager.assertCanCall(estimatedTokens);
1119
1769
  } catch (e) {
1120
1770
  const msg = `LLM budget exhausted: ${e.message}`;
1121
1771
  this.warnings.push(msg);
1122
- globalThis.setTimeout(() => process.removeListener("unhandledRejection", onUnhandled), 100);
1772
+ globalThis.setTimeout(
1773
+ () => process.removeListener("unhandledRejection", onUnhandled),
1774
+ 100
1775
+ );
1123
1776
  return { success: false, fallback: true, error: msg };
1124
1777
  }
1125
1778
  if (!this.isValidJsonSchema(schema)) {
1126
1779
  const msg = "Invalid JSON schema provided";
1127
1780
  this.warnings.push(msg);
1128
- globalThis.setTimeout(() => process.removeListener("unhandledRejection", onUnhandled), 100);
1781
+ globalThis.setTimeout(
1782
+ () => process.removeListener("unhandledRejection", onUnhandled),
1783
+ 100
1784
+ );
1129
1785
  return { success: false, fallback: true, error: msg };
1130
1786
  }
1131
1787
  const strictUserPrompt = `${userPrompt}
@@ -1144,22 +1800,36 @@ No explanations. No markdown. No additional text.`;
1144
1800
  void clientPromise.catch(() => {
1145
1801
  });
1146
1802
  const timeoutPromise = new Promise((_, reject) => {
1147
- const id = globalThis.setTimeout(() => reject(new Error(`LLM call timeout after ${this.timeoutMs}ms`)), this.timeoutMs);
1148
- void clientPromise.finally(() => globalThis.clearTimeout(id));
1803
+ const id = globalThis.setTimeout(
1804
+ () => reject(new Error(`LLM call timeout after ${this.timeoutMs}ms`)),
1805
+ this.timeoutMs
1806
+ );
1807
+ void clientPromise.finally(() => globalThis.clearTimeout(id)).catch(() => {
1808
+ });
1149
1809
  });
1150
1810
  void timeoutPromise.catch(() => {
1151
1811
  });
1152
- const response = await Promise.race([clientPromise, timeoutPromise]);
1812
+ const response = await Promise.race([
1813
+ clientPromise,
1814
+ timeoutPromise
1815
+ ]);
1153
1816
  if (!response || !response.content) {
1154
1817
  const graceMs = Math.min(Math.max(this.timeoutMs * 2, 100), 500);
1155
1818
  try {
1156
- await Promise.race([clientPromise.catch(() => {
1157
- }), new Promise((r) => setTimeout(r, graceMs))]);
1158
- } catch (e) {
1819
+ await Promise.race([
1820
+ clientPromise.catch(() => {
1821
+ }),
1822
+ new Promise((r) => setTimeout(r, graceMs))
1823
+ ]);
1824
+ } catch {
1159
1825
  } finally {
1160
1826
  process.removeListener("unhandledRejection", onUnhandled);
1161
1827
  }
1162
- return { success: false, fallback: true, error: "Empty response from LLM" };
1828
+ return {
1829
+ success: false,
1830
+ fallback: true,
1831
+ error: "Empty response from LLM"
1832
+ };
1163
1833
  }
1164
1834
  let parsedContent;
1165
1835
  try {
@@ -1173,9 +1843,12 @@ No explanations. No markdown. No additional text.`;
1173
1843
  this.warnings.push(msg);
1174
1844
  const graceMs = Math.min(Math.max(this.timeoutMs * 2, 100), 500);
1175
1845
  try {
1176
- await Promise.race([clientPromise.catch(() => {
1177
- }), new Promise((r) => setTimeout(r, graceMs))]);
1178
- } catch (e2) {
1846
+ await Promise.race([
1847
+ clientPromise.catch(() => {
1848
+ }),
1849
+ new Promise((r) => setTimeout(r, graceMs))
1850
+ ]);
1851
+ } catch {
1179
1852
  } finally {
1180
1853
  process.removeListener("unhandledRejection", onUnhandled);
1181
1854
  }
@@ -1186,9 +1859,12 @@ No explanations. No markdown. No additional text.`;
1186
1859
  this.warnings.push(msg);
1187
1860
  const graceMs = Math.min(Math.max(this.timeoutMs * 2, 100), 500);
1188
1861
  try {
1189
- await Promise.race([clientPromise.catch(() => {
1190
- }), new Promise((r) => setTimeout(r, graceMs))]);
1191
- } catch (e) {
1862
+ await Promise.race([
1863
+ clientPromise.catch(() => {
1864
+ }),
1865
+ new Promise((r) => setTimeout(r, graceMs))
1866
+ ]);
1867
+ } catch {
1192
1868
  } finally {
1193
1869
  process.removeListener("unhandledRejection", onUnhandled);
1194
1870
  }
@@ -1208,9 +1884,12 @@ No explanations. No markdown. No additional text.`;
1208
1884
  this.warnings.push(msg);
1209
1885
  const graceMs = Math.min(Math.max(this.timeoutMs * 2, 100), 500);
1210
1886
  try {
1211
- await Promise.race([clientPromise.catch(() => {
1212
- }), new Promise((r) => setTimeout(r, graceMs))]);
1213
- } catch (e2) {
1887
+ await Promise.race([
1888
+ clientPromise.catch(() => {
1889
+ }),
1890
+ new Promise((r) => setTimeout(r, graceMs))
1891
+ ]);
1892
+ } catch {
1214
1893
  } finally {
1215
1894
  process.removeListener("unhandledRejection", onUnhandled);
1216
1895
  }
@@ -1260,7 +1939,7 @@ No explanations. No markdown. No additional text.`;
1260
1939
  validateAgainstSchema(data, schema) {
1261
1940
  try {
1262
1941
  return validateJsonSchema(data, schema);
1263
- } catch (e) {
1942
+ } catch {
1264
1943
  if (typeof data !== "object" || data === null) {
1265
1944
  return false;
1266
1945
  }
@@ -1320,7 +1999,15 @@ var sectionClassificationSchema = {
1320
1999
  },
1321
2000
  classification: {
1322
2001
  type: "string",
1323
- enum: ["summary", "experience", "skills", "education", "projects", "certifications", "other"],
2002
+ enum: [
2003
+ "summary",
2004
+ "experience",
2005
+ "skills",
2006
+ "education",
2007
+ "projects",
2008
+ "certifications",
2009
+ "other"
2010
+ ],
1324
2011
  description: "Classified section type"
1325
2012
  },
1326
2013
  confidence: {
@@ -1623,7 +2310,10 @@ function analyzeResume(input) {
1623
2310
  let suggestions = suggestionResult.suggestions;
1624
2311
  const llmWarnings = [];
1625
2312
  if (input.llm && suggestionResult.suggestions.length > 0) {
1626
- const llmResult = enhanceSuggestionsWithLLM(input.llm, suggestionResult.suggestions);
2313
+ const llmResult = enhanceSuggestionsWithLLM(
2314
+ input.llm,
2315
+ suggestionResult.suggestions
2316
+ );
1627
2317
  if (llmResult.success) {
1628
2318
  suggestions = llmResult.enhancedSuggestions || suggestions;
1629
2319
  }
@@ -1643,21 +2333,25 @@ function analyzeResume(input) {
1643
2333
  achievementStrength: scoring.achievementStrength,
1644
2334
  matchedLanguages: scoring.matchedLanguages,
1645
2335
  missingLanguages: scoring.missingLanguages,
2336
+ skillExperienceGaps: scoring.skillExperienceGaps,
1646
2337
  experienceGap: scoring.experienceGap,
1647
2338
  detectedSections: parsedResume.detectedSections,
1648
2339
  parsedExperienceYears: parsedResume.totalExperienceYears,
1649
2340
  experienceEntries: parsedResume.experience,
2341
+ parseabilityReport: scoring.parseabilityReport,
2342
+ employmentGaps: scoring.employmentGaps,
2343
+ seniorityMatch: scoring.seniorityMatch,
2344
+ perSkillExperience: scoring.perSkillExperience,
1650
2345
  suggestions,
1651
2346
  warnings: [...suggestionResult.warnings, ...llmWarnings]
1652
2347
  };
1653
2348
  }
1654
- function enhanceSuggestionsWithLLM(config, suggestions) {
2349
+ function enhanceSuggestionsWithLLM(config, _suggestions) {
1655
2350
  if (!config.enable?.suggestions) {
1656
2351
  return { success: false, warnings: [] };
1657
2352
  }
1658
2353
  const warnings = [];
1659
2354
  try {
1660
- const llmManager = new LLMManager(config);
1661
2355
  warnings.push(
1662
2356
  "LLM suggestion enhancement skipped - use async analyzeResumeAsync for LLM features"
1663
2357
  );
@@ -1714,10 +2408,15 @@ async function analyzeResumeAsync(input) {
1714
2408
  achievementStrength: scoring.achievementStrength,
1715
2409
  matchedLanguages: scoring.matchedLanguages,
1716
2410
  missingLanguages: scoring.missingLanguages,
2411
+ skillExperienceGaps: scoring.skillExperienceGaps,
1717
2412
  experienceGap: scoring.experienceGap,
1718
2413
  detectedSections: parsedResume.detectedSections,
1719
2414
  parsedExperienceYears: parsedResume.totalExperienceYears,
1720
2415
  experienceEntries: parsedResume.experience,
2416
+ parseabilityReport: scoring.parseabilityReport,
2417
+ employmentGaps: scoring.employmentGaps,
2418
+ seniorityMatch: scoring.seniorityMatch,
2419
+ perSkillExperience: scoring.perSkillExperience,
1721
2420
  suggestions,
1722
2421
  warnings: [...suggestionResult.warnings, ...llmWarnings]
1723
2422
  };
@@ -1739,13 +2438,19 @@ async function enhanceSuggestionsWithLLMAsync(config, suggestions) {
1739
2438
  if (result.error) {
1740
2439
  warnings.push(`LLM suggestion enhancement failed: ${result.error}`);
1741
2440
  }
1742
- return { success: false, warnings: [...warnings, ...llmManager.getWarnings()] };
2441
+ return {
2442
+ success: false,
2443
+ warnings: [...warnings, ...llmManager.getWarnings()]
2444
+ };
1743
2445
  }
1744
2446
  const enhanced = adaptSuggestionEnhancementResponse(result.data);
1745
2447
  const enhancedSuggestions = enhanced.filter((e) => e.actionable !== false).map((e) => e.enhanced);
1746
2448
  if (enhancedSuggestions.length === 0) {
1747
2449
  warnings.push("LLM returned no actionable enhanced suggestions");
1748
- return { success: false, warnings: [...warnings, ...llmManager.getWarnings()] };
2450
+ return {
2451
+ success: false,
2452
+ warnings: [...warnings, ...llmManager.getWarnings()]
2453
+ };
1749
2454
  }
1750
2455
  return {
1751
2456
  success: true,
@@ -1753,7 +2458,9 @@ async function enhanceSuggestionsWithLLMAsync(config, suggestions) {
1753
2458
  warnings: llmManager.getWarnings()
1754
2459
  };
1755
2460
  } catch (e) {
1756
- warnings.push(`Unexpected error in LLM enhancement: ${e.message}`);
2461
+ warnings.push(
2462
+ `Unexpected error in LLM enhancement: ${e.message}`
2463
+ );
1757
2464
  return { success: false, warnings };
1758
2465
  }
1759
2466
  }