resuml 3.1.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/data/jobs/companies.json +76 -0
- package/dist/ats/index.d.ts +3 -79
- package/dist/ats/index.js +1 -1
- package/dist/{chunk-N55EPZ2N.js → chunk-C2JG5KF4.js} +263 -11
- package/dist/chunk-C2JG5KF4.js.map +1 -0
- package/dist/chunk-QBCXFLW6.js +686 -0
- package/dist/chunk-QBCXFLW6.js.map +1 -0
- package/dist/{chunk-M6JY5UDJ.js → chunk-YVC53STN.js} +221 -2
- package/dist/chunk-YVC53STN.js.map +1 -0
- package/dist/cli.js +3 -2
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +12 -2
- 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 +129 -1
- package/dist/mcp/server.js.map +1 -1
- package/dist/types-B_jASYU4.d.ts +81 -0
- package/package.json +6 -1
- package/dist/chunk-M6JY5UDJ.js.map +0 -1
- package/dist/chunk-N55EPZ2N.js.map +0 -1
package/DOCS.md
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
- [Installation](#installation)
|
|
9
9
|
- [CLI commands and options](#cli-commands-and-options)
|
|
10
10
|
- [ATS analysis](#ats-analysis)
|
|
11
|
+
- [Job search](#job-search)
|
|
11
12
|
- [Themes](#themes)
|
|
12
13
|
- [Example YAML structure](#example-yaml-structure)
|
|
13
14
|
- [CI/CD integration](#cicd-auto-build-on-push)
|
|
@@ -47,21 +48,32 @@ Requires Node.js ≥ 20 and npm ≥ 10.
|
|
|
47
48
|
| `pdf` | Render to PDF |
|
|
48
49
|
| `dev` | Dev server with hot-reload |
|
|
49
50
|
| `mcp` | Start the MCP server for AI agents |
|
|
51
|
+
| `jobs search` | Discover and rank job postings from free sources |
|
|
52
|
+
| `jobs score` | Score a single posting against your resume |
|
|
53
|
+
| `jobs tailor` | Generate a tailoring prompt for a posting |
|
|
50
54
|
|
|
51
55
|
### Options
|
|
52
56
|
|
|
53
57
|
| Option | Alias | Description |
|
|
54
58
|
| ----------------- | ----- | -------------------------------------------- |
|
|
55
|
-
| `--resume` | `-r` | Input YAML file(s) or directory
|
|
56
|
-
| `--output` | `-o` | Output file path
|
|
57
|
-
| `--theme` | `-t` | Theme name
|
|
58
|
-
| `--port` | `-p` | Dev server port (default: 3000)
|
|
59
|
-
| `--language` | | Locale (default: `en`)
|
|
60
|
-
| `--debug` | | Detailed errors
|
|
61
|
-
| `--ats` | | Run ATS analysis (with `validate`)
|
|
62
|
-
| `--jd` | | Path to job description file (with `--ats`)
|
|
63
|
-
| `--ats-threshold` | | Minimum score (0-100); exits 1 if below
|
|
64
|
-
| `--format` | | Output format for validate: `text` or `json`
|
|
59
|
+
| `--resume` | `-r` | Input YAML file(s) or directory |
|
|
60
|
+
| `--output` | `-o` | Output file path |
|
|
61
|
+
| `--theme` | `-t` | Theme name |
|
|
62
|
+
| `--port` | `-p` | Dev server port (default: 3000) |
|
|
63
|
+
| `--language` | | Locale (default: `en`) |
|
|
64
|
+
| `--debug` | | Detailed errors |
|
|
65
|
+
| `--ats` | | Run ATS analysis (with `validate`) |
|
|
66
|
+
| `--jd` | | Path to job description file (with `--ats`) |
|
|
67
|
+
| `--ats-threshold` | | Minimum score (0-100); exits 1 if below |
|
|
68
|
+
| `--format` | | Output format for validate: `text` or `json` |
|
|
69
|
+
| `--location` | | Override candidate location as `"City, CC"` (with `jobs search`) |
|
|
70
|
+
| `--min-score` | | Minimum ATS score for results (default 85, with `jobs search`) |
|
|
71
|
+
| `--limit` | | Max postings returned after ranking (default 20, with `jobs search`) |
|
|
72
|
+
| `--providers` | | Comma-separated provider list (with `jobs search`) |
|
|
73
|
+
| `--remote` | | Restrict to remote-friendly postings (with `jobs search`) |
|
|
74
|
+
| `--posting` | | Posting YAML/JSON file for `jobs score` / `jobs tailor` |
|
|
75
|
+
| `--body` | | Posting body text; use `-` for stdin (with `jobs tailor`) |
|
|
76
|
+
| `--json` | | Machine-readable JSON output (with `jobs search`, `score`, `tailor`) |
|
|
65
77
|
|
|
66
78
|
### Quick start
|
|
67
79
|
|
|
@@ -141,6 +153,49 @@ Supports English and German (language-specific action verbs and pronouns). Use `
|
|
|
141
153
|
resuml validate --resume lebenslauf.yaml --ats --language de
|
|
142
154
|
```
|
|
143
155
|
|
|
156
|
+
## Job search
|
|
157
|
+
|
|
158
|
+
Discover, score, and tailor job postings against your resume — all offline, no API keys.
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
# Search free job sources and rank results against your resume (default minScore: 85)
|
|
162
|
+
resuml jobs search -r resume.yaml
|
|
163
|
+
|
|
164
|
+
# Filter to remote-friendly postings in your country
|
|
165
|
+
resuml jobs search -r resume.yaml --remote --location "Zürich, CH"
|
|
166
|
+
|
|
167
|
+
# Score a single posting (full per-tier ATS breakdown)
|
|
168
|
+
resuml jobs score -r resume.yaml --posting posting.yaml
|
|
169
|
+
|
|
170
|
+
# Generate a tailoring prompt for a posting (pipe output to Claude)
|
|
171
|
+
resuml jobs tailor --posting posting.yaml
|
|
172
|
+
resuml jobs tailor --body - < jd.txt # read body from stdin
|
|
173
|
+
|
|
174
|
+
# Machine-readable output
|
|
175
|
+
resuml jobs search -r resume.yaml --json
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Posting file format (`--posting posting.yaml`)
|
|
179
|
+
|
|
180
|
+
```yaml
|
|
181
|
+
company: Acme Corp
|
|
182
|
+
title: Senior Backend Engineer
|
|
183
|
+
url: https://acme.com/jobs/123
|
|
184
|
+
body: |
|
|
185
|
+
We are looking for a backend engineer with 5+ years of experience...
|
|
186
|
+
location: Berlin, DE # optional
|
|
187
|
+
remote: false # optional
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### How search works
|
|
191
|
+
|
|
192
|
+
1. Derives a query from your resume (seniority, top skills, location).
|
|
193
|
+
2. Fetches postings from free sources in parallel (Greenhouse, Lever, Ashby, Workable, RemoteOK, WWR, Remotive, HN Who's Hiring).
|
|
194
|
+
3. Drops on-site postings in the wrong country when `--location` is set.
|
|
195
|
+
4. Scores every posting with the full ATS engine; dedupes by `(company, title, location)`.
|
|
196
|
+
5. Removes postings below `--min-score` (default 85) and caps to `--limit` (default 20).
|
|
197
|
+
6. Off-specialty roles (e.g. a backend JD for a frontend CV) are capped at 45 and filtered out automatically.
|
|
198
|
+
|
|
144
199
|
## Themes
|
|
145
200
|
|
|
146
201
|
resuml supports any `jsonresume-theme-*` package from npm. Install a theme, then pass its name to `--theme`:
|
|
@@ -277,14 +332,18 @@ Claude Code will:
|
|
|
277
332
|
|
|
278
333
|
### Tools
|
|
279
334
|
|
|
280
|
-
| Tool
|
|
281
|
-
|
|
|
282
|
-
| `resuml_init_resume`
|
|
283
|
-
| `resuml_validate`
|
|
284
|
-
| `resuml_ats_check`
|
|
285
|
-
| `
|
|
286
|
-
| `
|
|
287
|
-
| `
|
|
335
|
+
| Tool | Purpose |
|
|
336
|
+
| ---------------------- | ------------------------------------------------------------------------------- |
|
|
337
|
+
| `resuml_init_resume` | Generate a starter YAML template |
|
|
338
|
+
| `resuml_validate` | Validate resume YAML against the JSON Resume schema |
|
|
339
|
+
| `resuml_ats_check` | Tiered ATS analysis (Parsing / Match / Recruiter) with knockout signals |
|
|
340
|
+
| `resuml_ats_explain` | Return the rubric entry for a specific check id |
|
|
341
|
+
| `resuml_render` | Render to HTML using a theme (supports `locale`) |
|
|
342
|
+
| `resuml_list_themes` | List available themes and install status |
|
|
343
|
+
| `resuml_export_pdf` | Export as PDF (supports `margin`, `locale`) |
|
|
344
|
+
| `resuml_jobs_search` | Discover and rank job postings from free sources against the resume |
|
|
345
|
+
| `resuml_jobs_score` | Score a single job posting against the resume; returns full ATS breakdown |
|
|
346
|
+
| `resuml_jobs_tailor` | Build a tailoring prompt for a specific posting (returns prompt text) |
|
|
288
347
|
|
|
289
348
|
### Resources
|
|
290
349
|
|
package/README.md
CHANGED
|
@@ -72,6 +72,11 @@ npm install -g jsonresume-theme-stackoverflow
|
|
|
72
72
|
resuml validate --resume resume.yaml --ats --jd job.txt
|
|
73
73
|
resuml render --resume resume.yaml --theme stackoverflow --output resume.html
|
|
74
74
|
resuml pdf --resume resume.yaml --theme stackoverflow --output resume.pdf
|
|
75
|
+
|
|
76
|
+
# discover and rank job postings from free sources
|
|
77
|
+
resuml jobs search --resume resume.yaml --location "Zürich, CH"
|
|
78
|
+
resuml jobs score --resume resume.yaml --posting posting.yaml
|
|
79
|
+
resuml jobs tailor --posting posting.yaml
|
|
75
80
|
```
|
|
76
81
|
|
|
77
82
|
`resuml pdf` and snapshot rendering need Playwright. Install it once with `npm install -g playwright` (it's an optional peer dep so the base install stays slim).
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Seed allowlists for ATS-direct providers. Slugs are the board-id segment of each company's public ATS URL. Curated from public job boards as of 2026-05. Add to this list via the --extra-companies CLI flag or extraCompanies SearchOption.",
|
|
3
|
+
"greenhouse": [
|
|
4
|
+
"airbnb",
|
|
5
|
+
"stripe",
|
|
6
|
+
"anthropic",
|
|
7
|
+
"openai",
|
|
8
|
+
"datadog",
|
|
9
|
+
"discord",
|
|
10
|
+
"duolingo",
|
|
11
|
+
"elastic",
|
|
12
|
+
"figma",
|
|
13
|
+
"gitlab",
|
|
14
|
+
"instacart",
|
|
15
|
+
"linear",
|
|
16
|
+
"lyft",
|
|
17
|
+
"mongodb",
|
|
18
|
+
"notion",
|
|
19
|
+
"nubank",
|
|
20
|
+
"pinterest",
|
|
21
|
+
"plaid",
|
|
22
|
+
"ramp",
|
|
23
|
+
"reddit",
|
|
24
|
+
"robinhood",
|
|
25
|
+
"samsara",
|
|
26
|
+
"scaleai",
|
|
27
|
+
"snowflake",
|
|
28
|
+
"squareup",
|
|
29
|
+
"twilio",
|
|
30
|
+
"vercel",
|
|
31
|
+
"wise",
|
|
32
|
+
"zapier"
|
|
33
|
+
],
|
|
34
|
+
"lever": [
|
|
35
|
+
"netflix",
|
|
36
|
+
"spotify",
|
|
37
|
+
"shopify",
|
|
38
|
+
"github",
|
|
39
|
+
"palantir",
|
|
40
|
+
"asana",
|
|
41
|
+
"intercom",
|
|
42
|
+
"kraken",
|
|
43
|
+
"leetcode",
|
|
44
|
+
"miro",
|
|
45
|
+
"patreon",
|
|
46
|
+
"showpad",
|
|
47
|
+
"thumbtack"
|
|
48
|
+
],
|
|
49
|
+
"ashby": [
|
|
50
|
+
"ashbyhq",
|
|
51
|
+
"openai",
|
|
52
|
+
"anthropic",
|
|
53
|
+
"ramp",
|
|
54
|
+
"linear",
|
|
55
|
+
"vercel",
|
|
56
|
+
"posthog",
|
|
57
|
+
"modal",
|
|
58
|
+
"replicate",
|
|
59
|
+
"perplexity",
|
|
60
|
+
"supabase",
|
|
61
|
+
"huggingface",
|
|
62
|
+
"ironclad",
|
|
63
|
+
"warp",
|
|
64
|
+
"writer"
|
|
65
|
+
],
|
|
66
|
+
"workable": [
|
|
67
|
+
"deliveroo",
|
|
68
|
+
"getyourguide",
|
|
69
|
+
"n26",
|
|
70
|
+
"personio",
|
|
71
|
+
"celonis",
|
|
72
|
+
"tier",
|
|
73
|
+
"wefox",
|
|
74
|
+
"blinkist"
|
|
75
|
+
]
|
|
76
|
+
}
|
package/dist/ats/index.d.ts
CHANGED
|
@@ -1,83 +1,7 @@
|
|
|
1
1
|
import { Resume as ResumeSchema } from '../types/index.js';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
type AtsCheckWeight = 'high' | 'medium' | 'low';
|
|
5
|
-
type AtsRating = 'excellent' | 'good' | 'needs-work' | 'poor';
|
|
6
|
-
type CheckStatus = 'pass' | 'warn' | 'fail' | 'skipped';
|
|
7
|
-
type Grade = 'A' | 'B' | 'C' | 'D' | 'F';
|
|
8
|
-
interface CheckResult {
|
|
9
|
-
id: string;
|
|
10
|
-
tier: Tier;
|
|
11
|
-
status: CheckStatus;
|
|
12
|
-
score: number;
|
|
13
|
-
weight: AtsCheckWeight;
|
|
14
|
-
message: string;
|
|
15
|
-
hints: string[];
|
|
16
|
-
}
|
|
17
|
-
interface TierResult {
|
|
18
|
-
score: number;
|
|
19
|
-
grade: Grade;
|
|
20
|
-
checks: CheckResult[];
|
|
21
|
-
}
|
|
22
|
-
interface KnockoutSignal {
|
|
23
|
-
signal: string;
|
|
24
|
-
evidence: string;
|
|
25
|
-
recommendation: string;
|
|
26
|
-
}
|
|
27
|
-
interface TieredAtsResult {
|
|
28
|
-
score: number;
|
|
29
|
-
rating: AtsRating;
|
|
30
|
-
tiers: {
|
|
31
|
-
parsing: TierResult;
|
|
32
|
-
match?: TierResult;
|
|
33
|
-
recruiter: TierResult;
|
|
34
|
-
};
|
|
35
|
-
knockouts: KnockoutSignal[];
|
|
36
|
-
summary: string;
|
|
37
|
-
}
|
|
38
|
-
interface AtsOptions {
|
|
39
|
-
language?: string;
|
|
40
|
-
jobDescription?: string;
|
|
41
|
-
threshold?: number;
|
|
42
|
-
config?: AtsConfig;
|
|
43
|
-
}
|
|
44
|
-
interface AtsConfig {
|
|
45
|
-
weights: {
|
|
46
|
-
tiers: {
|
|
47
|
-
parsing: number;
|
|
48
|
-
match: number;
|
|
49
|
-
recruiter: number;
|
|
50
|
-
};
|
|
51
|
-
checks: Record<string, AtsCheckWeight>;
|
|
52
|
-
};
|
|
53
|
-
thresholds: {
|
|
54
|
-
rating: {
|
|
55
|
-
excellent: number;
|
|
56
|
-
good: number;
|
|
57
|
-
needsWork: number;
|
|
58
|
-
};
|
|
59
|
-
grade: {
|
|
60
|
-
A: number;
|
|
61
|
-
B: number;
|
|
62
|
-
C: number;
|
|
63
|
-
D: number;
|
|
64
|
-
};
|
|
65
|
-
seniorYoeCutoff: number;
|
|
66
|
-
wordCount: {
|
|
67
|
-
min: number;
|
|
68
|
-
max: number;
|
|
69
|
-
seniorMax: number;
|
|
70
|
-
};
|
|
71
|
-
bulletsPerRole: {
|
|
72
|
-
min: number;
|
|
73
|
-
max: number;
|
|
74
|
-
seniorMax: number;
|
|
75
|
-
};
|
|
76
|
-
};
|
|
77
|
-
disable: string[];
|
|
78
|
-
locale: string;
|
|
79
|
-
}
|
|
2
|
+
import { A as AtsOptions, T as TieredAtsResult } from '../types-B_jASYU4.js';
|
|
3
|
+
export { C as CheckResult, K as KnockoutSignal, a as TierResult } from '../types-B_jASYU4.js';
|
|
80
4
|
|
|
81
5
|
declare function analyzeAts(resume: ResumeSchema, options?: AtsOptions): TieredAtsResult;
|
|
82
6
|
|
|
83
|
-
export {
|
|
7
|
+
export { AtsOptions, TieredAtsResult, analyzeAts };
|
package/dist/ats/index.js
CHANGED
|
@@ -1430,6 +1430,176 @@ function matchJobDescription(resume, jobDescription, _language = "en") {
|
|
|
1430
1430
|
return { matched, missing, extra, matchPercentage };
|
|
1431
1431
|
}
|
|
1432
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
|
+
|
|
1433
1603
|
// src/ats/checks/match.ts
|
|
1434
1604
|
var SENIORITY = /\b(junior|senior|lead|staff|principal|head of|vp|chief)\b/gi;
|
|
1435
1605
|
var STOPWORDS = /* @__PURE__ */ new Set([
|
|
@@ -1470,7 +1640,7 @@ function extractJdTitle(jd) {
|
|
|
1470
1640
|
);
|
|
1471
1641
|
return fallback ? trimTitle(fallback) : void 0;
|
|
1472
1642
|
}
|
|
1473
|
-
var titleAlignment = (resume, _l, { jobDescription }) => {
|
|
1643
|
+
var titleAlignment = (resume, _l, { jobDescription, jobTitle }) => {
|
|
1474
1644
|
if (!jobDescription) {
|
|
1475
1645
|
return {
|
|
1476
1646
|
id: "title-alignment",
|
|
@@ -1483,16 +1653,16 @@ var titleAlignment = (resume, _l, { jobDescription }) => {
|
|
|
1483
1653
|
};
|
|
1484
1654
|
}
|
|
1485
1655
|
const resumeTitle = resume.work?.[0]?.position || resume.basics?.label;
|
|
1486
|
-
const jdTitle = extractJdTitle(jobDescription);
|
|
1656
|
+
const jdTitle = jobTitle?.trim() ? trimTitle(jobTitle) : extractJdTitle(jobDescription);
|
|
1487
1657
|
if (!resumeTitle || !jdTitle) {
|
|
1488
1658
|
return {
|
|
1489
1659
|
id: "title-alignment",
|
|
1490
1660
|
tier: "match",
|
|
1491
1661
|
weight: "high",
|
|
1492
|
-
status: "
|
|
1493
|
-
score:
|
|
1494
|
-
message: "Could not extract title from JD or resume.",
|
|
1495
|
-
hints: ["Set basics.label to your target title."]
|
|
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."]
|
|
1496
1666
|
};
|
|
1497
1667
|
}
|
|
1498
1668
|
const j = jaccard(tokenize(resumeTitle), tokenize(jdTitle));
|
|
@@ -1618,7 +1788,81 @@ var hardSkillOverlap = (resume, language, { jobDescription }) => {
|
|
|
1618
1788
|
hints: status === "pass" ? [] : km.missing.slice(0, 5).map((s) => `Add evidence of "${s}" to skills/highlights.`)
|
|
1619
1789
|
};
|
|
1620
1790
|
};
|
|
1621
|
-
var
|
|
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
|
+
];
|
|
1622
1866
|
var KNOCKOUTS = [
|
|
1623
1867
|
{
|
|
1624
1868
|
signal: "work-auth",
|
|
@@ -1807,19 +2051,26 @@ function analyzeAts(resume, options = {}) {
|
|
|
1807
2051
|
const recruiter = buildTier("recruiter", recruiterChecks, cfg);
|
|
1808
2052
|
let match;
|
|
1809
2053
|
let knockouts = [];
|
|
2054
|
+
let roleMismatch = false;
|
|
1810
2055
|
if (options.jobDescription) {
|
|
1811
2056
|
const matchChecks = allMatchChecks.map(
|
|
1812
|
-
(fn) => fn(resume, language, { jobDescription: options.jobDescription })
|
|
2057
|
+
(fn) => fn(resume, language, { jobDescription: options.jobDescription, jobTitle: options.jobTitle })
|
|
1813
2058
|
);
|
|
1814
2059
|
match = buildTier("match", matchChecks, cfg);
|
|
2060
|
+
roleMismatch = matchChecks.some(
|
|
2061
|
+
(c) => c.id === "role-family-match" && c.status === "fail"
|
|
2062
|
+
);
|
|
1815
2063
|
knockouts = extractKnockouts(resume, options.jobDescription);
|
|
1816
2064
|
}
|
|
1817
|
-
|
|
2065
|
+
let totalScore = computeTotalScore(
|
|
1818
2066
|
{ parsing: parsing.score, match: match?.score, recruiter: recruiter.score },
|
|
1819
2067
|
cfg.weights.tiers
|
|
1820
2068
|
);
|
|
2069
|
+
const capped = roleMismatch && totalScore > ROLE_MISMATCH_CAP;
|
|
2070
|
+
if (capped) totalScore = ROLE_MISMATCH_CAP;
|
|
1821
2071
|
const rating = scoreToRating(totalScore, cfg.thresholds.rating);
|
|
1822
|
-
|
|
2072
|
+
let summary = generateSummary(totalScore, rating, !!options.jobDescription, knockouts.length);
|
|
2073
|
+
if (capped) summary += " Score capped: resume role family does not match this posting.";
|
|
1823
2074
|
return {
|
|
1824
2075
|
score: totalScore,
|
|
1825
2076
|
rating,
|
|
@@ -1828,9 +2079,10 @@ function analyzeAts(resume, options = {}) {
|
|
|
1828
2079
|
summary
|
|
1829
2080
|
};
|
|
1830
2081
|
}
|
|
2082
|
+
var ROLE_MISMATCH_CAP = 45;
|
|
1831
2083
|
|
|
1832
2084
|
export {
|
|
1833
2085
|
loadConfig,
|
|
1834
2086
|
analyzeAts
|
|
1835
2087
|
};
|
|
1836
|
-
//# sourceMappingURL=chunk-
|
|
2088
|
+
//# sourceMappingURL=chunk-C2JG5KF4.js.map
|