@juicedresume/mcp 0.3.2 → 0.4.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.js +180 -61
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1415,15 +1415,29 @@ function bigrams(toks) {
|
|
|
1415
1415
|
return out;
|
|
1416
1416
|
}
|
|
1417
1417
|
function resumeText(resume) {
|
|
1418
|
-
const parts = [];
|
|
1419
|
-
parts.push(resume.personal.title);
|
|
1418
|
+
const parts = [resume.personal.title];
|
|
1420
1419
|
for (const sec of resume.sections) {
|
|
1420
|
+
if (sec.visible === false) continue;
|
|
1421
1421
|
for (const it of sec.items) {
|
|
1422
|
-
|
|
1423
|
-
parts.push(
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1422
|
+
if (it.visible === false) continue;
|
|
1423
|
+
parts.push(
|
|
1424
|
+
it.body || "",
|
|
1425
|
+
it.description || "",
|
|
1426
|
+
it.skills || "",
|
|
1427
|
+
it.category || "",
|
|
1428
|
+
it.jobTitle || "",
|
|
1429
|
+
it.degree || "",
|
|
1430
|
+
it.field || "",
|
|
1431
|
+
it.name || "",
|
|
1432
|
+
it.title || "",
|
|
1433
|
+
it.role || "",
|
|
1434
|
+
it.employer || "",
|
|
1435
|
+
it.school || "",
|
|
1436
|
+
it.publisher || "",
|
|
1437
|
+
it.institution || "",
|
|
1438
|
+
it.issuer || "",
|
|
1439
|
+
it.location || ""
|
|
1440
|
+
);
|
|
1427
1441
|
}
|
|
1428
1442
|
}
|
|
1429
1443
|
return parts.join(" ").replace(/<[^>]+>/g, " ");
|
|
@@ -1433,16 +1447,16 @@ function tailorToJob(resume, jdText) {
|
|
|
1433
1447
|
const resumeFullText = resumeText(resume).toLowerCase();
|
|
1434
1448
|
const resumeToks = new Set(tokens(resumeFullText));
|
|
1435
1449
|
const resumeNorm = new Set([...resumeToks].map((t) => normalizeSkill(t).toLowerCase()));
|
|
1436
|
-
function
|
|
1450
|
+
function matchKind(keyword) {
|
|
1437
1451
|
const k = keyword.toLowerCase();
|
|
1438
1452
|
if (k.includes(" ")) {
|
|
1439
|
-
return new RegExp(`\\b${escapeRe(k)}\\b`).test(resumeFullText);
|
|
1453
|
+
return new RegExp(`\\b${escapeRe(k)}\\b`).test(resumeFullText) ? "exact" : "none";
|
|
1440
1454
|
}
|
|
1441
|
-
if (resumeToks.has(k)) return
|
|
1455
|
+
if (resumeToks.has(k)) return "exact";
|
|
1442
1456
|
const canon = normalizeSkill(keyword).toLowerCase();
|
|
1443
|
-
if (resumeNorm.has(canon)) return
|
|
1444
|
-
if (canon.includes(" ") && new RegExp(`\\b${escapeRe(canon)}\\b`).test(resumeFullText)) return
|
|
1445
|
-
return
|
|
1457
|
+
if (resumeNorm.has(canon)) return "variant";
|
|
1458
|
+
if (canon.includes(" ") && new RegExp(`\\b${escapeRe(canon)}\\b`).test(resumeFullText)) return "variant";
|
|
1459
|
+
return "none";
|
|
1446
1460
|
}
|
|
1447
1461
|
const jdGrams = /* @__PURE__ */ new Set([...jdToks, ...bigrams(jdToks)]);
|
|
1448
1462
|
const skillNames = new Map(SKILLS.map((s) => [s.name.toLowerCase(), s]));
|
|
@@ -1460,10 +1474,20 @@ function tailorToJob(resume, jdText) {
|
|
|
1460
1474
|
const matched = [];
|
|
1461
1475
|
const missing = [];
|
|
1462
1476
|
const kindMult = (kind) => kind === "hard" ? 2 : kind === "soft" ? 1.3 : 1;
|
|
1477
|
+
const VARIANT_CREDIT = 0.6;
|
|
1463
1478
|
for (const [k, meta] of jdSkills) {
|
|
1464
1479
|
const w = meta.importance * kindMult(meta.kind);
|
|
1465
|
-
|
|
1480
|
+
const kind = matchKind(k);
|
|
1481
|
+
if (kind === "exact") {
|
|
1466
1482
|
matched.push({ keyword: k, kind: meta.kind, count: w });
|
|
1483
|
+
} else if (kind === "variant") {
|
|
1484
|
+
matched.push({
|
|
1485
|
+
keyword: k,
|
|
1486
|
+
kind: meta.kind,
|
|
1487
|
+
count: w * VARIANT_CREDIT,
|
|
1488
|
+
partial: true,
|
|
1489
|
+
note: `You use a variant of "${k}". ATS keyword search is usually exact-match, so add the exact term "${k}" too.`
|
|
1490
|
+
});
|
|
1467
1491
|
} else {
|
|
1468
1492
|
missing.push({ keyword: k, kind: meta.kind, importance: w });
|
|
1469
1493
|
}
|
|
@@ -1483,9 +1507,71 @@ function tailorToJob(resume, jdText) {
|
|
|
1483
1507
|
missing,
|
|
1484
1508
|
jdSkills: jdSkills.size,
|
|
1485
1509
|
resumeSkills: resumeToks.size,
|
|
1486
|
-
titleMatch
|
|
1510
|
+
titleMatch,
|
|
1511
|
+
flags: jdKnockoutFlags(resume, jdText)
|
|
1487
1512
|
};
|
|
1488
1513
|
}
|
|
1514
|
+
function tailorMMYYYY(d) {
|
|
1515
|
+
if (!d) return null;
|
|
1516
|
+
const m = d.match(/^(\d{1,2})\/(\d{4})$/);
|
|
1517
|
+
if (m) return parseInt(m[2], 10) * 12 + parseInt(m[1], 10);
|
|
1518
|
+
const mn = d.match(/^([A-Za-z]+)\s+(\d{4})$/);
|
|
1519
|
+
if (mn) {
|
|
1520
|
+
const months = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
|
1521
|
+
const i = months.findIndex((x) => mn[1].toLowerCase().startsWith(x));
|
|
1522
|
+
if (i >= 0) return parseInt(mn[2], 10) * 12 + (i + 1);
|
|
1523
|
+
}
|
|
1524
|
+
const y = d.match(/^(\d{4})$/);
|
|
1525
|
+
if (y) return parseInt(y[1], 10) * 12;
|
|
1526
|
+
return null;
|
|
1527
|
+
}
|
|
1528
|
+
function resumeYears(resume) {
|
|
1529
|
+
const exp = resume.sections.find((s) => s.type === "experience");
|
|
1530
|
+
if (!exp) return 0;
|
|
1531
|
+
const now = (/* @__PURE__ */ new Date()).getFullYear() * 12 + ((/* @__PURE__ */ new Date()).getMonth() + 1);
|
|
1532
|
+
let months = 0;
|
|
1533
|
+
for (const it of exp.items || []) {
|
|
1534
|
+
if (it.visible === false) continue;
|
|
1535
|
+
const s = tailorMMYYYY(it.startDate || "");
|
|
1536
|
+
const e = it.current ? now : tailorMMYYYY(it.endDate || "");
|
|
1537
|
+
if (s && e && e >= s) months += e - s;
|
|
1538
|
+
}
|
|
1539
|
+
return Math.round(months / 12 * 10) / 10;
|
|
1540
|
+
}
|
|
1541
|
+
function jdKnockoutFlags(resume, jdText) {
|
|
1542
|
+
const flags = [];
|
|
1543
|
+
const degreeRequired = /\b(bachelor|master|b\.?s\.?|m\.?s\.?|b\.?a\.?|ph\.?d|mba|degree)\b[^.\n]{0,40}\b(required|require|must|minimum|mandatory)\b/i.test(jdText) || /\b(required|must have|minimum|mandatory)\b[^.\n]{0,40}\b(bachelor|master|degree|b\.?s\.?|m\.?s\.?|ph\.?d|diploma)\b/i.test(jdText);
|
|
1544
|
+
if (degreeRequired) {
|
|
1545
|
+
const eduSec = resume.sections.find((s) => s.type === "education");
|
|
1546
|
+
const hasDegree = (eduSec?.items || []).some((it) => it.visible !== false && String(it.degree || "").trim());
|
|
1547
|
+
if (!hasDegree) {
|
|
1548
|
+
flags.push({
|
|
1549
|
+
id: "K4.missing-required-degree",
|
|
1550
|
+
severity: "risk",
|
|
1551
|
+
title: "This job states a degree requirement",
|
|
1552
|
+
detail: "The description asks for a specific degree, and no completed degree is listed on your resume. A stated degree requirement is often a hard knockout filter.",
|
|
1553
|
+
suggestion: "Add your degree in the Education section, or if you have equivalent experience, be ready to address the gap in a cover note.",
|
|
1554
|
+
requirement: "degree"
|
|
1555
|
+
});
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
const yoe = [...jdText.matchAll(/(\d{1,2})\s*\+?\s*(?:to|-|–|—)?\s*\d{0,2}\s*years?(?:['’]?s?)?\s*(?:of\s+)?(?:experience|exp\b)/gi)].map((m) => parseInt(m[1], 10)).filter((n) => n >= 1 && n <= 20);
|
|
1559
|
+
if (yoe.length) {
|
|
1560
|
+
const required = Math.min(...yoe);
|
|
1561
|
+
const have = resumeYears(resume);
|
|
1562
|
+
if (have + 0.5 < required) {
|
|
1563
|
+
flags.push({
|
|
1564
|
+
id: "K5.yoe-below-requirement",
|
|
1565
|
+
severity: "risk",
|
|
1566
|
+
title: `Asks for ${required}+ years; your resume shows about ${have}`,
|
|
1567
|
+
detail: "Years of experience is one of the most common automated and recruiter filters. A year or more below the stated minimum is a frequent screen-out.",
|
|
1568
|
+
suggestion: "Make sure every relevant role has dates so your full tenure is counted, and include earlier or concurrent experience if it applies.",
|
|
1569
|
+
requirement: `${required}+ years`
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
return flags;
|
|
1574
|
+
}
|
|
1489
1575
|
|
|
1490
1576
|
// ../../packages/scoring/src/v2-additions.ts
|
|
1491
1577
|
function stripHtml(s) {
|
|
@@ -1531,41 +1617,6 @@ function firstBulletStrongest(resume) {
|
|
|
1531
1617
|
)
|
|
1532
1618
|
};
|
|
1533
1619
|
}
|
|
1534
|
-
function dateFormatConsistency(resume) {
|
|
1535
|
-
const exp = resume.sections.find((s) => s.type === "experience");
|
|
1536
|
-
const edu = resume.sections.find((s) => s.type === "education");
|
|
1537
|
-
const dates = [];
|
|
1538
|
-
[...exp?.items || [], ...edu?.items || []].forEach((it) => {
|
|
1539
|
-
if (it.startDate) dates.push(it.startDate);
|
|
1540
|
-
if (it.endDate && !it.current) dates.push(it.endDate);
|
|
1541
|
-
});
|
|
1542
|
-
if (dates.length < 2) return null;
|
|
1543
|
-
const MM_YYYY = /^\d{1,2}\/\d{4}$/;
|
|
1544
|
-
const MMM_YYYY = /^[A-Za-z]+\s+\d{4}$/;
|
|
1545
|
-
const YYYY = /^\d{4}$/;
|
|
1546
|
-
const ABBREV = /['']\d{2}|\d{1,2}\/\d{2}|\b[A-Z][a-z]{2}'\d{2}\b/;
|
|
1547
|
-
const formats = /* @__PURE__ */ new Set();
|
|
1548
|
-
for (const d of dates) {
|
|
1549
|
-
if (ABBREV.test(d)) formats.add("abbrev");
|
|
1550
|
-
else if (MM_YYYY.test(d)) formats.add("mm/yyyy");
|
|
1551
|
-
else if (MMM_YYYY.test(d)) formats.add("month-yyyy");
|
|
1552
|
-
else if (YYYY.test(d)) formats.add("yyyy");
|
|
1553
|
-
else formats.add("other");
|
|
1554
|
-
}
|
|
1555
|
-
const hasAbbrev = formats.has("abbrev");
|
|
1556
|
-
const inconsistent = formats.size > 1;
|
|
1557
|
-
const score = hasAbbrev ? 0.2 : inconsistent ? 0.55 : 1;
|
|
1558
|
-
return {
|
|
1559
|
-
dim: "style",
|
|
1560
|
-
check: passSoft(
|
|
1561
|
-
"C9.date-format",
|
|
1562
|
-
"Date format is consistent",
|
|
1563
|
-
score,
|
|
1564
|
-
hasAbbrev ? "Date abbreviations like '21 or Jan'24 break ATS years-of-experience calculation." : inconsistent ? `You're mixing date formats (${[...formats].join(", ")}). Pick one and apply consistently.` : "Dates are formatted consistently \u2014 ATS parses them cleanly.",
|
|
1565
|
-
"Use MM/YYYY (06/2024) or Month YYYY (June 2024) \u2014 consistently across every entry."
|
|
1566
|
-
)
|
|
1567
|
-
};
|
|
1568
|
-
}
|
|
1569
1620
|
function targetTitle(resume) {
|
|
1570
1621
|
const title = resume.personal?.title?.trim() || "";
|
|
1571
1622
|
const score = title.length >= 3 ? 1 : 0;
|
|
@@ -1626,7 +1677,6 @@ function threeLocationsRule(resume) {
|
|
|
1626
1677
|
function additionalChecks(resume) {
|
|
1627
1678
|
return [
|
|
1628
1679
|
firstBulletStrongest(resume),
|
|
1629
|
-
dateFormatConsistency(resume),
|
|
1630
1680
|
targetTitle(resume),
|
|
1631
1681
|
dobAgePenalty(resume),
|
|
1632
1682
|
threeLocationsRule(resume)
|
|
@@ -2642,7 +2692,8 @@ function atsChecks(resume, b) {
|
|
|
2642
2692
|
const allText = b.bullets.map((x) => x.text).join(" ");
|
|
2643
2693
|
const isSingleCol = !isMultiColumnTemplate(s.template);
|
|
2644
2694
|
const isSafeFont = SAFE_FONTS.has(s.fontHeading) && SAFE_FONTS.has(s.fontBody);
|
|
2645
|
-
const
|
|
2695
|
+
const fontSizeIdeal = s.fontSize >= 10 && s.fontSize <= 12;
|
|
2696
|
+
const fontSizeAcceptable = s.fontSize >= 9.5 && s.fontSize <= 12.5;
|
|
2646
2697
|
const safeBullet = SAFE_BULLET_GLYPHS.has(s.bulletGlyph === "disc" ? "\u2022" : s.bulletGlyph === "dash" ? "-" : "");
|
|
2647
2698
|
const emailOK = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(p.email);
|
|
2648
2699
|
const phoneOK = !p.phone || /(\+?\d[\d\s().-]{6,})/.test(p.phone);
|
|
@@ -2684,10 +2735,14 @@ function atsChecks(resume, b) {
|
|
|
2684
2735
|
// single-column), but ~1 in 10 still scrambles reading order and columns
|
|
2685
2736
|
// cost human skimmability. A single, evidence-scaled ding — the overall
|
|
2686
2737
|
// score also takes a small proportional multi-column haircut (see below).
|
|
2687
|
-
|
|
2688
|
-
|
|
2738
|
+
// ~0.85 reflects the real ~90% correct column-extraction rate reported by
|
|
2739
|
+
// Textkernel, so the within-dimension ding stays light; the small overall
|
|
2740
|
+
// multi-column haircut in scoreResume carries the actual score effect
|
|
2741
|
+
// (we deliberately don't double-penalize with a 0.55 here).
|
|
2742
|
+
isSingleCol ? 1 : 0.85,
|
|
2743
|
+
isSingleCol ? "Single-column layout. Every ATS parses this cleanly." : "Multi-column template. Modern parsers handle columns about 90% of the time, but a minority scramble reading order, and columns are harder for a recruiter to skim.",
|
|
2689
2744
|
[],
|
|
2690
|
-
!isSingleCol ? "
|
|
2745
|
+
!isSingleCol ? "Exporting via the ATS PDF button always outputs a single-column, parse-clean file, so you can keep this template on screen and still submit a safe PDF. Or switch to a single-column template (Jake, Harvard, Classic) for a single source." : void 0,
|
|
2691
2746
|
"soft"
|
|
2692
2747
|
),
|
|
2693
2748
|
mk(
|
|
@@ -2776,10 +2831,10 @@ function atsChecks(resume, b) {
|
|
|
2776
2831
|
mk(
|
|
2777
2832
|
"E11.font-size",
|
|
2778
2833
|
"Body font size 10\u201312pt",
|
|
2779
|
-
|
|
2780
|
-
|
|
2834
|
+
fontSizeIdeal ? 1 : fontSizeAcceptable ? 0.75 : 0.5,
|
|
2835
|
+
fontSizeIdeal ? `Font size ${s.fontSize}pt is in the ideal 10\u201312pt band.` : fontSizeAcceptable ? `Font size ${s.fontSize}pt is readable but outside the ideal 10\u201312pt band.` : `Font size ${s.fontSize}pt is outside the readable 9.5\u201312.5pt range.`,
|
|
2781
2836
|
[],
|
|
2782
|
-
!
|
|
2837
|
+
!fontSizeIdeal ? "Set body size to 10\u201312pt for the best balance of density and legibility." : void 0,
|
|
2783
2838
|
void 0,
|
|
2784
2839
|
0.5
|
|
2785
2840
|
),
|
|
@@ -2841,7 +2896,7 @@ function skillsChecks(resume, b) {
|
|
|
2841
2896
|
stuffed.length === 0 ? 1 : 1 - Math.min(1, stuffed.length / 3),
|
|
2842
2897
|
stuffed.length === 0 ? "No token over-repeated." : `Over-repeated: ${stuffed.slice(0, 4).map(([k, v]) => `${k}\xD7${v}`).join(", ")}.`,
|
|
2843
2898
|
[],
|
|
2844
|
-
stuffed.length ? "Diversify language
|
|
2899
|
+
stuffed.length ? "Diversify your language. Repeating the same keyword more than 8 times reads as stuffing, and frequency past the first mention does not help ATS ranking anyway." : void 0
|
|
2845
2900
|
),
|
|
2846
2901
|
mk(
|
|
2847
2902
|
"F5.acronym-expansion",
|
|
@@ -2928,7 +2983,9 @@ function polishChecks(resume, b) {
|
|
|
2928
2983
|
hasHiddenText ? "Hidden or invisible text (white-on-white, zero-size) or an AI-instruction string was detected. ATS convert your file to plain text, so these become visible and read as gaming the system." : "No hidden text or prompt-injection tricks detected.",
|
|
2929
2984
|
[],
|
|
2930
2985
|
hasHiddenText ? "Remove any white/invisible text, zero-size fonts, or 'rate me highly'-style instructions. They backfire once the ATS strips formatting." : void 0,
|
|
2931
|
-
|
|
2986
|
+
// Hard severity: hidden text / prompt-injection is the one trick that
|
|
2987
|
+
// genuinely backfires, so a positive detection caps the overall score.
|
|
2988
|
+
"hard",
|
|
2932
2989
|
2
|
|
2933
2990
|
)
|
|
2934
2991
|
];
|
|
@@ -2972,6 +3029,52 @@ function experienceYears(resume) {
|
|
|
2972
3029
|
}
|
|
2973
3030
|
return Math.floor(months / 12);
|
|
2974
3031
|
}
|
|
3032
|
+
function computeFlags(resume) {
|
|
3033
|
+
const flags = [];
|
|
3034
|
+
const p = resume.personal;
|
|
3035
|
+
const emailOK = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(p.email);
|
|
3036
|
+
const hasPhone = !!(p.phone && /(\+?\d[\d\s().-]{6,})/.test(p.phone));
|
|
3037
|
+
const nameOK = !!(p.fullName && p.fullName.trim().length >= 2);
|
|
3038
|
+
if (!(nameOK && (emailOK || hasPhone))) {
|
|
3039
|
+
flags.push({
|
|
3040
|
+
id: "K1.contact-unparseable",
|
|
3041
|
+
severity: "block",
|
|
3042
|
+
title: "Contact details can't be parsed",
|
|
3043
|
+
detail: !nameOK ? "No full name is set. ATS build the candidate record from a name plus an email or phone, so without a name the whole resume can be dropped." : "No valid email or phone is set. Most parsers reject a record that has neither.",
|
|
3044
|
+
suggestion: "Add your full name and at least one of a valid email or phone."
|
|
3045
|
+
});
|
|
3046
|
+
}
|
|
3047
|
+
const expSec = resume.sections.find((s) => s.type === "experience");
|
|
3048
|
+
const expItems = (expSec?.items || []).filter((it) => it.visible !== false);
|
|
3049
|
+
const datelessRoles = expItems.filter((it) => !parseMMYYYY(it.startDate || ""));
|
|
3050
|
+
if (expItems.length > 0 && datelessRoles.length > 0) {
|
|
3051
|
+
flags.push({
|
|
3052
|
+
id: "K2.role-without-dates",
|
|
3053
|
+
severity: "risk",
|
|
3054
|
+
title: `${datelessRoles.length} role${datelessRoles.length === 1 ? "" : "s"} missing dates`,
|
|
3055
|
+
detail: "A role with no start date can't be ordered or counted toward years of experience by a parser, and years-of-experience filters are a top recruiter screen.",
|
|
3056
|
+
suggestion: "Add a start and end date (MM/YYYY or Month YYYY) to every role."
|
|
3057
|
+
});
|
|
3058
|
+
}
|
|
3059
|
+
const nowMonths = (/* @__PURE__ */ new Date()).getFullYear() * 12 + ((/* @__PURE__ */ new Date()).getMonth() + 1);
|
|
3060
|
+
const ranges = expItems.map((it) => ({ start: parseMMYYYY(it.startDate || ""), end: it.current ? nowMonths : parseMMYYYY(it.endDate || it.startDate || "") })).filter((r) => r.start && r.end);
|
|
3061
|
+
ranges.sort((a, b) => b.start - a.start);
|
|
3062
|
+
let maxGap = 0;
|
|
3063
|
+
for (let i = 0; i < ranges.length - 1; i++) {
|
|
3064
|
+
const gap = ranges[i].start - ranges[i + 1].end;
|
|
3065
|
+
if (gap > maxGap) maxGap = gap;
|
|
3066
|
+
}
|
|
3067
|
+
if (maxGap > 6) {
|
|
3068
|
+
flags.push({
|
|
3069
|
+
id: "K3.employment-gap",
|
|
3070
|
+
severity: "risk",
|
|
3071
|
+
title: `About a ${maxGap}-month employment gap`,
|
|
3072
|
+
detail: "Some employers screen for gaps over 6 months. A gap is legitimate, but an unexplained one lowers callback odds.",
|
|
3073
|
+
suggestion: "Add a one-line note (contract, study, caregiving) or a short bridging entry so the gap does not read as unexplained."
|
|
3074
|
+
});
|
|
3075
|
+
}
|
|
3076
|
+
return flags;
|
|
3077
|
+
}
|
|
2975
3078
|
function aggregate(checks, weight) {
|
|
2976
3079
|
if (!checks.length) return { score: 0, weighted: 0 };
|
|
2977
3080
|
let wsum = 0, wtot = 0;
|
|
@@ -3001,7 +3104,7 @@ var LABELS = {
|
|
|
3001
3104
|
skills: "Skills & Keywords",
|
|
3002
3105
|
polish: "Completeness & Polish"
|
|
3003
3106
|
};
|
|
3004
|
-
function scoreResume(resume) {
|
|
3107
|
+
function scoreResume(resume, opts) {
|
|
3005
3108
|
const b = gather(resume);
|
|
3006
3109
|
const dims = [
|
|
3007
3110
|
{ dim: "impact", checks: impactChecks(resume, b) },
|
|
@@ -3036,6 +3139,21 @@ function scoreResume(resume) {
|
|
|
3036
3139
|
}
|
|
3037
3140
|
const hardFail = dimensions.some((d) => d.checks.some((c) => c.severity === "hard" && c.status === "fail"));
|
|
3038
3141
|
if (hardFail) overall = Math.min(overall, 60);
|
|
3142
|
+
const parseFindings = opts?.parseFindings ?? [];
|
|
3143
|
+
const parseHard = parseFindings.some((f) => f.severity === "hard");
|
|
3144
|
+
const parseSoftCount = parseFindings.filter((f) => f.severity === "soft").length;
|
|
3145
|
+
if (parseHard) overall = Math.min(overall, 55);
|
|
3146
|
+
if (parseSoftCount) overall = Math.max(0, overall - Math.min(12, parseSoftCount * 4));
|
|
3147
|
+
const flags = [
|
|
3148
|
+
...computeFlags(resume),
|
|
3149
|
+
...parseFindings.map((f) => ({
|
|
3150
|
+
id: f.id,
|
|
3151
|
+
severity: f.severity === "hard" ? "block" : "risk",
|
|
3152
|
+
title: f.title,
|
|
3153
|
+
detail: f.detail,
|
|
3154
|
+
suggestion: f.suggestion ?? null
|
|
3155
|
+
}))
|
|
3156
|
+
];
|
|
3039
3157
|
const sortedDims = [...dimensions].sort((a, b2) => a.score - b2.score);
|
|
3040
3158
|
const worst = sortedDims[0];
|
|
3041
3159
|
const best = sortedDims[sortedDims.length - 1];
|
|
@@ -3048,6 +3166,7 @@ function scoreResume(resume) {
|
|
|
3048
3166
|
summary: `Strongest: ${best.label} (${best.score}). Focus area: ${worst.label} (${worst.score}).`,
|
|
3049
3167
|
priorities,
|
|
3050
3168
|
hardFails: dimensions.flatMap((d) => d.checks.filter((c) => c.severity === "hard" && c.status === "fail").map((c) => ({ ...c, dim: d.dimension }))),
|
|
3169
|
+
flags,
|
|
3051
3170
|
stats: {
|
|
3052
3171
|
totalBullets: b.bullets.length,
|
|
3053
3172
|
experienceBullets: b.expBullets.length,
|
package/package.json
CHANGED