@whiteslove/parsing-lexicon 0.6.17 → 0.7.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/index.d.ts +6 -0
- package/package.json +21 -13
- package/src/contact.d.ts +2 -2
- package/src/contact.js +31 -4
- package/src/geo.js +2 -2
- package/src/hiring-ats.d.ts +6 -0
- package/src/hiring-ats.js +153 -0
- package/src/hiring-skills.d.ts +8 -0
- package/src/hiring-skills.js +82 -0
- package/src/hiring-temporal.js +1 -1
- package/src/housing-address.d.ts +46 -0
- package/src/housing-address.js +364 -32
- package/src/housing-commercial.d.ts +1 -0
- package/src/housing-commercial.js +20 -0
- package/src/housing-listing-enrichment.d.ts +38 -1
- package/src/housing-listing-enrichment.js +16 -2
- package/src/housing-money.d.ts +3 -0
- package/src/housing-money.js +195 -8
- package/src/housing-parser-v2.d.ts +20 -0
- package/src/housing-parser-v2.js +132 -0
- package/src/housing-poi-relations.d.ts +5 -0
- package/src/housing-poi-relations.js +115 -0
- package/src/index.js +6 -0
- package/src/parser-core.d.ts +11 -0
- package/src/parser-core.js +69 -0
- package/src/tashkent-housing-geography.js +3 -3
- package/src/temporal.d.ts +7 -0
- package/src/temporal.js +144 -0
- package/src/ua-geo-coordinates.d.ts +0 -14
- package/src/ua-geo-coordinates.js +0 -209
- package/src/ua-geo-resolver.d.ts +0 -31
- package/src/ua-geo-resolver.js +0 -171
- package/src/uz-geo-coordinates.d.ts +0 -14
- package/src/uz-geo-coordinates.js +0 -166
package/index.d.ts
CHANGED
|
@@ -371,15 +371,21 @@ export function classifyHiringIntent(value: unknown): Readonly<{ intent: 'candid
|
|
|
371
371
|
export function parseExperience(value: unknown): ExperienceParseResult | null;
|
|
372
372
|
|
|
373
373
|
export * from './src/housing-context.js';
|
|
374
|
+
export * from './src/housing-address.js';
|
|
375
|
+
export * from './src/parser-core.js';
|
|
376
|
+
export * from './src/temporal.js';
|
|
374
377
|
export * from './src/hiring-context.js';
|
|
375
378
|
export * from './src/housing-intent.js';
|
|
376
379
|
export * from './src/housing-structured.js';
|
|
380
|
+
export * from './src/housing-parser-v2.js';
|
|
377
381
|
export * from './src/housing-safety.js';
|
|
378
382
|
export * from './src/housing-title.js';
|
|
379
383
|
export * from './src/housing-language.js';
|
|
380
384
|
export * from './src/housing-features.js';
|
|
381
385
|
export * from './src/housing-listing-fields.js';
|
|
382
386
|
export * from './src/housing-card-fields.js';
|
|
387
|
+
export * from './src/hiring-skills.js';
|
|
388
|
+
export * from './src/hiring-ats.js';
|
|
383
389
|
|
|
384
390
|
// housing semantic helper declarations added in 0.2.5
|
|
385
391
|
export function resolveHousingOccupancy(value: unknown): 'wholeProperty' | 'room' | 'sharedRoom' | 'bedSpace' | null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@whiteslove/parsing-lexicon",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Shared deterministic multilingual parsing lexicon for WhitesLove housing and hiring services",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -16,6 +16,14 @@
|
|
|
16
16
|
},
|
|
17
17
|
"./lexicon-core": "./src/lexicon-core.js",
|
|
18
18
|
"./normalization": "./src/normalization.js",
|
|
19
|
+
"./parser-core": {
|
|
20
|
+
"types": "./src/parser-core.d.ts",
|
|
21
|
+
"import": "./src/parser-core.js"
|
|
22
|
+
},
|
|
23
|
+
"./temporal": {
|
|
24
|
+
"types": "./src/temporal.d.ts",
|
|
25
|
+
"import": "./src/temporal.js"
|
|
26
|
+
},
|
|
19
27
|
"./countries": "./src/countries.js",
|
|
20
28
|
"./country-context": {
|
|
21
29
|
"types": "./src/country-context.d.ts",
|
|
@@ -37,18 +45,6 @@
|
|
|
37
45
|
},
|
|
38
46
|
"./central-asia": "./src/central-asia.js",
|
|
39
47
|
"./ukraine": "./src/ukraine.js",
|
|
40
|
-
"./ua-geo-coordinates": {
|
|
41
|
-
"types": "./src/ua-geo-coordinates.d.ts",
|
|
42
|
-
"import": "./src/ua-geo-coordinates.js"
|
|
43
|
-
},
|
|
44
|
-
"./ua-geo-resolver": {
|
|
45
|
-
"types": "./src/ua-geo-resolver.d.ts",
|
|
46
|
-
"import": "./src/ua-geo-resolver.js"
|
|
47
|
-
},
|
|
48
|
-
"./uz-geo-coordinates": {
|
|
49
|
-
"types": "./src/uz-geo-coordinates.d.ts",
|
|
50
|
-
"import": "./src/uz-geo-coordinates.js"
|
|
51
|
-
},
|
|
52
48
|
"./location-merge": "./src/location-merge.js",
|
|
53
49
|
"./locations": "./src/locations-runtime.js",
|
|
54
50
|
"./kg-location-extensions": "./src/kg-location-extensions.js",
|
|
@@ -118,6 +114,10 @@
|
|
|
118
114
|
"import": "./src/housing-source-aliases.js"
|
|
119
115
|
},
|
|
120
116
|
"./housing-poi-extensions": "./src/housing-poi-extensions.js",
|
|
117
|
+
"./housing-poi-relations": {
|
|
118
|
+
"types": "./src/housing-poi-relations.d.ts",
|
|
119
|
+
"import": "./src/housing-poi-relations.js"
|
|
120
|
+
},
|
|
121
121
|
"./housing-display": {
|
|
122
122
|
"types": "./src/housing-display.d.ts",
|
|
123
123
|
"import": "./src/housing-display.js"
|
|
@@ -203,6 +203,14 @@
|
|
|
203
203
|
"import": "./src/hiring-skills.js"
|
|
204
204
|
},
|
|
205
205
|
"./hiring-advanced": "./src/hiring-advanced.js",
|
|
206
|
+
"./hiring-ats": {
|
|
207
|
+
"types": "./src/hiring-ats.d.ts",
|
|
208
|
+
"import": "./src/hiring-ats.js"
|
|
209
|
+
},
|
|
210
|
+
"./housing-parser-v2": {
|
|
211
|
+
"types": "./src/housing-parser-v2.d.ts",
|
|
212
|
+
"import": "./src/housing-parser-v2.js"
|
|
213
|
+
},
|
|
206
214
|
"./hiring-safety": {
|
|
207
215
|
"types": "./src/hiring-safety.d.ts",
|
|
208
216
|
"import": "./src/hiring-safety.js"
|
package/src/contact.d.ts
CHANGED
|
@@ -36,8 +36,8 @@ export type TelegramContact = Readonly<{
|
|
|
36
36
|
source: 'mention' | 'url' | 'tg';
|
|
37
37
|
}>;
|
|
38
38
|
|
|
39
|
-
export function findPhoneLikeSpans(value: unknown): readonly PhoneLikeSpan[];
|
|
40
|
-
export function maskPhoneLikeSpans(value: unknown, replacement?: string): string;
|
|
39
|
+
export function findPhoneLikeSpans(value: unknown, options?: { country?: string | null; countryHint?: string | null }): readonly PhoneLikeSpan[];
|
|
40
|
+
export function maskPhoneLikeSpans(value: unknown, replacement?: string | { country?: string | null; countryHint?: string | null }, options?: { country?: string | null; countryHint?: string | null }): string;
|
|
41
41
|
export function parsePhoneNumbers(value: unknown, options?: ParsePhoneOptions): readonly ParsedPhoneNumber[];
|
|
42
42
|
export function normalizePhone(value: unknown, options?: ParsePhoneOptions): ParsedPhoneNumber | null;
|
|
43
43
|
export function findTelegramContacts(value: unknown): readonly TelegramContact[];
|
package/src/contact.js
CHANGED
|
@@ -28,8 +28,9 @@ function splitPhoneExtension(raw) {
|
|
|
28
28
|
};
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
export function findPhoneLikeSpans(value) {
|
|
31
|
+
export function findPhoneLikeSpans(value, options = {}) {
|
|
32
32
|
const text = String(value || '');
|
|
33
|
+
const countryHint = normalizedCountryHint(options.country || options.countryHint);
|
|
33
34
|
const spans = [];
|
|
34
35
|
for (const match of text.matchAll(PHONE_LIKE_RE)) {
|
|
35
36
|
const raw = match[0];
|
|
@@ -43,12 +44,38 @@ export function findPhoneLikeSpans(value) {
|
|
|
43
44
|
digits,
|
|
44
45
|
}));
|
|
45
46
|
}
|
|
46
|
-
|
|
47
|
+
|
|
48
|
+
// Uzbekistan's ordinary national presentation is nine digits (for example
|
|
49
|
+
// 99 188 19 19). It is too short for the deliberately country-neutral
|
|
50
|
+
// broad mask above, but libphonenumber can validate/identify it when the
|
|
51
|
+
// caller has already established UZ context. Do not generalize this to all
|
|
52
|
+
// countries: a bare nine digit number is often a price or a house number.
|
|
53
|
+
if (countryHint === 'UZ') {
|
|
54
|
+
for (const candidate of parsePhoneNumbers(text, { countryHint, includePossible: true })) {
|
|
55
|
+
// The short masking exception is intentionally narrow. `99` is a
|
|
56
|
+
// common Uzbekistan mobile prefix in listings; accepting every possible
|
|
57
|
+
// nine-digit national number would hide legitimate UZS sale prices.
|
|
58
|
+
if (candidate.digits.length !== 9 || !candidate.digits.startsWith('99') || !candidate.possible) continue;
|
|
59
|
+
if (spans.some((span) => span.start <= candidate.start && span.end >= candidate.end)) continue;
|
|
60
|
+
spans.push(Object.freeze({
|
|
61
|
+
start: candidate.start,
|
|
62
|
+
end: candidate.end,
|
|
63
|
+
raw: candidate.raw,
|
|
64
|
+
digits: candidate.digits,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return Object.freeze(spans.sort((a, b) => a.start - b.start || b.end - a.end));
|
|
47
69
|
}
|
|
48
70
|
|
|
49
|
-
export function maskPhoneLikeSpans(value, replacement = ' ') {
|
|
71
|
+
export function maskPhoneLikeSpans(value, replacement = ' ', options = {}) {
|
|
72
|
+
// Backward-compatible convenience: maskPhoneLikeSpans(value, { country }).
|
|
73
|
+
if (replacement && typeof replacement === 'object') {
|
|
74
|
+
options = replacement;
|
|
75
|
+
replacement = ' ';
|
|
76
|
+
}
|
|
50
77
|
const text = String(value || '');
|
|
51
|
-
const spans = findPhoneLikeSpans(text);
|
|
78
|
+
const spans = findPhoneLikeSpans(text, options);
|
|
52
79
|
if (!spans.length) return text;
|
|
53
80
|
|
|
54
81
|
let out = '';
|
package/src/geo.js
CHANGED
|
@@ -20,7 +20,7 @@ export const TASHKENT_DISTRICTS = Object.freeze([
|
|
|
20
20
|
entity('Yunusabad', { uzLatn: ['Yunusobod', 'Yunusobod tumani', 'Yunusabad'], uzCyrl: ['Юнусобод', 'Юнусобод тумани'], ru: ['Юнусабад', 'Юнусабадский район'], en: ['Yunusabad', 'Yunusobod'] }),
|
|
21
21
|
entity('Yakkasaray', { uzLatn: ['Yakkasaroy', 'Yakkasaroy tumani', 'Yakkasaray'], uzCyrl: ['Яккасарой', 'Яккасарой тумани'], ru: ['Яккасарай', 'Яккасарайский район'], en: ['Yakkasaray', 'Yakkasaroy'] }),
|
|
22
22
|
entity('Yangihayot', { uzLatn: ['Yangihayot', 'Yangihayot tumani'], uzCyrl: ['Янгиҳаёт', 'Янгиҳаёт тумани', 'Янгихаёт'], ru: ['Янгихаётский район', 'Янгихаёт'], en: ['Yangihayot'] }),
|
|
23
|
-
entity('Yashnobod', { uzLatn: ['Yashnobod', 'Yashnobod tumani', 'Yashnabod', 'Yashnabod tumani'], uzCyrl: ['Яшнобод', 'Яшнобод тумани'], ru: ['Яшнабад', 'Яшнабадский район'], en: ['Yashnobod', 'Yashnabad'] }),
|
|
23
|
+
entity('Yashnobod', { uzLatn: ['Yashnobod', 'Yashnobod tumani', 'Yashnabod', 'Yashnabod tumani', 'Yashnobot', 'Yashnobot tumani'], uzCyrl: ['Яшнобод', 'Яшнобод тумани'], ru: ['Яшнабад', 'Яшнабадский район'], en: ['Yashnobod', 'Yashnabad'] }),
|
|
24
24
|
]);
|
|
25
25
|
|
|
26
26
|
export function canonicalTashkentDistrict(value) {
|
|
@@ -176,4 +176,4 @@ export const TASHKENT_AREAS = Object.freeze({
|
|
|
176
176
|
...[1, 2, 3, 4].map((n) => area(`Tuzel-${n}`, [`тузель ${n}`, `tuzel ${n}`])),
|
|
177
177
|
area('Tashselmash', ['ташсельмаш', 'tashselmash']), area('Alimkent', ['алимкент', 'olimkent', 'alimkent']), area('Shohimardon', ['шохимардон', 'shohimardon']),
|
|
178
178
|
]),
|
|
179
|
-
});
|
|
179
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { HiringSeniority, DegreeLevel, DegreeField } from './hiring-requirements.js';
|
|
2
|
+
export type HiringAtsProfile = Readonly<{ raw: string; skills: Set<string>; skillEvidence: Map<string, number>; terms?: Set<string>; experienceYears?: number; seniority?: HiringSeniority; degreeLevel?: DegreeLevel; degreeFields: Set<DegreeField>; requiresUsSponsorship?: boolean }>;
|
|
3
|
+
export type HiringAtsJob = Readonly<{ title: string; description?: string; tags?: string[]; skills?: string[]; niceToHave?: string[]; experienceMinYears?: number; seniority?: string | null; education?: string; country?: string; location?: string }>;
|
|
4
|
+
export function buildHiringAtsProfile(cvText: unknown, options?: { fuzzySkills?: boolean; referenceDate?: Date }): HiringAtsProfile;
|
|
5
|
+
export function scoreHiringAts(profileOrCv: HiringAtsProfile | string, job: HiringAtsJob, options?: { fuzzySkills?: boolean; referenceDate?: Date }): Readonly<{ score: number; fitScore: number; eligible: boolean; blockers: readonly { code: string; label: string; critical: boolean }[]; breakdown: Readonly<{ skills: number; experience: number; seniority: number; scope: number; education: number; relevance: number }>; matched: readonly string[]; missing: readonly string[] }>;
|
|
6
|
+
export function hiringAtsScoreColor(score: number): string;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { detectCountryCodeFromText } from './geography-detection.js';
|
|
2
|
+
import {
|
|
3
|
+
SENIORITY_RANK, bucketVacancyText, classifyCvSectionHeading, detectDegreeFields,
|
|
4
|
+
detectDegreeLevel, detectHiringSeniority, extractCvExperienceYears,
|
|
5
|
+
extractRequiredExperienceYears, isNoSponsorshipRequirement, requiresUsSponsorship,
|
|
6
|
+
} from './hiring-requirements.js';
|
|
7
|
+
import { detectDegreeRequirement, detectHiringScopeSignals } from './hiring-semantics.js';
|
|
8
|
+
import { canonicalSkillName, extractSkillNames, matchSkillCandidates } from './hiring-skills.js';
|
|
9
|
+
|
|
10
|
+
const SECTION_WEIGHT = Object.freeze({ experience: 1, projects: 0.7, profile: 0.55, skills: 0.4, education: 0.35, other: 0.45 });
|
|
11
|
+
const DEGREE_RANK = Object.freeze({ secondary: 0, bachelor: 1, master: 2, doctorate: 3 });
|
|
12
|
+
const SCOPE_LABELS = Object.freeze({ architecture: 'Architecture / system design', leadership: 'Technical leadership', mentoring: 'Mentoring engineers', scale: 'Large-scale systems', ownership: 'Product / feature ownership' });
|
|
13
|
+
const TERM_STOP_WORDS = new Set(['the', 'and', 'for', 'with', 'that', 'this', 'from', 'into', 'your', 'you', 'our', 'are', 'will', 'have', 'has', 'who', 'what', 'when', 'where', 'which', 'their', 'they', 'them', 'about', 'within', 'across', 'using', 'including', 'work', 'working', 'team', 'teams', 'role', 'company', 'years', 'year', 'experience', 'skills', 'skill', 'strong', 'good', 'excellent', 'ability', 'knowledge', 'looking', 'required', 'requirements', 'preferred', 'responsibilities', 'opportunity', 'candidate', 'position', 'professional', 'develop', 'development', 'build', 'building', 'software', 'engineer', 'engineering', 'help', 'support', 'ensure', 'provide', 'plus', 'nice', 'must', 'need', 'needs', 'для', 'что', 'как', 'или', 'это', 'мы', 'вы', 'ваш', 'ваша', 'ваши', 'наш', 'наша', 'наши', 'работа', 'работы', 'работать', 'опыт', 'лет', 'года', 'год', 'команда', 'команды', 'знание', 'знания', 'навыки', 'требования', 'обязанности', 'будет', 'нужно', 'необходимо', 'умение', 'разработка', 'разработки', 'позиция', 'кандидат']);
|
|
14
|
+
|
|
15
|
+
function canonicalSet(values = []) {
|
|
16
|
+
return new Set(values.map((value) => canonicalSkillName(value)).filter(Boolean));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function extractTerms(value) {
|
|
20
|
+
const words = String(value || '').toLowerCase().replace(/[’']/g, '').match(/[a-zа-яёіїєґ][a-zа-яёіїєґ0-9+#.-]{2,}/giu) || [];
|
|
21
|
+
return new Set(words.filter((word) => !TERM_STOP_WORDS.has(word) && !/^\d+$/u.test(word)));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function coverage(have, wanted) {
|
|
25
|
+
if (!wanted.size) return 0;
|
|
26
|
+
let found = 0;
|
|
27
|
+
for (const value of wanted) if (have.has(value)) found += 1;
|
|
28
|
+
return found / wanted.size;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function evidenceFromCv(raw, { fuzzySkills = false } = {}) {
|
|
32
|
+
const evidence = new Map();
|
|
33
|
+
let section = 'other';
|
|
34
|
+
const add = (line, weight) => {
|
|
35
|
+
for (const name of extractSkillNames(line)) evidence.set(name, Math.max(evidence.get(name) || 0, weight));
|
|
36
|
+
if (!fuzzySkills) return;
|
|
37
|
+
for (const match of matchSkillCandidates(line, { fuzzy: true, allowAmbiguousExact: section === 'skills' })) {
|
|
38
|
+
// Fuzzy evidence is deliberately discounted relative to direct canonical
|
|
39
|
+
// or alias evidence in the same section, and never overrides it.
|
|
40
|
+
const score = match.matchType === 'fuzzy' ? weight * Math.min(0.75, match.confidence) : weight;
|
|
41
|
+
evidence.set(match.canonical, Math.max(evidence.get(match.canonical) || 0, score));
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
for (const line of String(raw || '').replace(/\r/g, '').split('\n')) {
|
|
45
|
+
const heading = classifyCvSectionHeading(line.trim());
|
|
46
|
+
if (heading) { section = heading; continue; }
|
|
47
|
+
if (line.trim()) add(line, SECTION_WEIGHT[section]);
|
|
48
|
+
}
|
|
49
|
+
return evidence;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function buildHiringAtsProfile(cvText, options = {}) {
|
|
53
|
+
const raw = String(cvText || '');
|
|
54
|
+
const skillEvidence = evidenceFromCv(raw, options);
|
|
55
|
+
return Object.freeze({
|
|
56
|
+
raw,
|
|
57
|
+
skills: new Set(skillEvidence.keys()), skillEvidence,
|
|
58
|
+
terms: extractTerms(raw),
|
|
59
|
+
experienceYears: extractCvExperienceYears(raw, options.referenceDate) ?? undefined,
|
|
60
|
+
seniority: detectHiringSeniority(raw) || undefined,
|
|
61
|
+
degreeLevel: detectDegreeLevel(raw) || undefined,
|
|
62
|
+
degreeFields: new Set(detectDegreeFields(raw)),
|
|
63
|
+
requiresUsSponsorship: requiresUsSponsorship(raw) ?? undefined,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function scoreSeniority(candidate, required) {
|
|
68
|
+
if (!required) return { score: 100, gap: 0 };
|
|
69
|
+
if (!candidate) return { score: 45, gap: 1 };
|
|
70
|
+
const gap = SENIORITY_RANK[required] - SENIORITY_RANK[candidate];
|
|
71
|
+
return { score: gap <= 0 ? 100 : gap === 1 ? 65 : gap === 2 ? 30 : 10, gap };
|
|
72
|
+
}
|
|
73
|
+
function scoreExperience(candidate, required) {
|
|
74
|
+
if (required === undefined) return { score: 100, gap: 0 };
|
|
75
|
+
if (candidate === undefined) return { score: 35, gap: required };
|
|
76
|
+
if (candidate >= required) return { score: 100, gap: 0 };
|
|
77
|
+
const ratio = candidate / required;
|
|
78
|
+
return { score: ratio >= .85 ? 75 : ratio >= .7 ? 55 : ratio >= .5 ? 35 : 15, gap: Math.max(0, required - candidate) };
|
|
79
|
+
}
|
|
80
|
+
function scoreScope(jobText, cvText) {
|
|
81
|
+
const required = detectHiringScopeSignals(jobText, { mode: 'vacancy' });
|
|
82
|
+
if (!required.length) return { score: 100, missing: [], requiredCount: 0 };
|
|
83
|
+
const have = new Set(detectHiringScopeSignals(cvText, { mode: 'candidate' }));
|
|
84
|
+
const missing = required.filter((code) => !have.has(code)).map((code) => SCOPE_LABELS[code] || code);
|
|
85
|
+
return { score: Math.round((1 - missing.length / required.length) * 100), missing, requiredCount: required.length };
|
|
86
|
+
}
|
|
87
|
+
function scoreEducation(profile, requirement) {
|
|
88
|
+
if (!requirement.level && !requirement.field) return { score: 100, fieldMismatch: false, levelMismatch: false };
|
|
89
|
+
const levelMismatch = requirement.level && (DEGREE_RANK[profile.degreeLevel] ?? -1) < DEGREE_RANK[requirement.level];
|
|
90
|
+
const fieldMismatch = requirement.field && !profile.degreeFields.has(requirement.field) && !(requirement.field === 'computer_science' && profile.degreeFields.has('engineering'));
|
|
91
|
+
if (levelMismatch) return { score: requirement.equivalentExperience ? 45 : 10, fieldMismatch: Boolean(fieldMismatch), levelMismatch: true };
|
|
92
|
+
if (fieldMismatch) return { score: requirement.equivalentExperience ? 60 : 35, fieldMismatch: true, levelMismatch: false };
|
|
93
|
+
return { score: 100, fieldMismatch: false, levelMismatch: false };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Portable, explainable ATS scoring; no browser, transport, or UI concerns. */
|
|
97
|
+
export function scoreHiringAts(profileOrCv, job, options = {}) {
|
|
98
|
+
const profile = typeof profileOrCv === 'string' ? buildHiringAtsProfile(profileOrCv, options) : profileOrCv;
|
|
99
|
+
const description = String(job?.description || ''); const title = String(job?.title || ''); const tagText = (job?.tags || []).join(' ');
|
|
100
|
+
const buckets = bucketVacancyText(description);
|
|
101
|
+
const titleSkills = new Set(extractSkillNames(title)); const requiredTextSkills = new Set(extractSkillNames(buckets.required));
|
|
102
|
+
const optionalTextSkills = new Set(extractSkillNames(buckets.optional)); const contextTextSkills = new Set(extractSkillNames(buckets.context));
|
|
103
|
+
const noiseSkills = new Set(extractSkillNames(buckets.noise)); const serverRequired = canonicalSet(job?.skills); const serverOptional = canonicalSet(job?.niceToHave);
|
|
104
|
+
const required = new Set([...titleSkills, ...requiredTextSkills]); const optional = new Set(optionalTextSkills); const context = new Set();
|
|
105
|
+
// Server tags can be useful context, but they only become required when the
|
|
106
|
+
// vacancy itself supplies title/requirement evidence. This preserves the
|
|
107
|
+
// established 4 / 1 / .35 evidence architecture.
|
|
108
|
+
for (const skill of serverRequired) {
|
|
109
|
+
if (noiseSkills.has(skill) && !required.has(skill)) continue;
|
|
110
|
+
if (requiredTextSkills.has(skill) || titleSkills.has(skill)) required.add(skill); else context.add(skill);
|
|
111
|
+
}
|
|
112
|
+
for (const skill of serverOptional) {
|
|
113
|
+
if (noiseSkills.has(skill) && !optionalTextSkills.has(skill)) continue;
|
|
114
|
+
optional.add(skill);
|
|
115
|
+
}
|
|
116
|
+
for (const skill of [...extractSkillNames(tagText), ...contextTextSkills]) {
|
|
117
|
+
if (noiseSkills.has(skill) || required.has(skill) || optional.has(skill)) continue;
|
|
118
|
+
context.add(skill);
|
|
119
|
+
}
|
|
120
|
+
for (const skill of required) { optional.delete(skill); context.delete(skill); }
|
|
121
|
+
for (const skill of optional) context.delete(skill);
|
|
122
|
+
let possible = 0; let earned = 0; const matched = []; const missingSkills = [];
|
|
123
|
+
const evidenceFor = (skill) => profile.skillEvidence.get(skill) ?? (profile.skills.has(skill) ? 0.45 : 0);
|
|
124
|
+
for (const [skills, weight, requiredSkill] of [[required, 4, true], [optional, 1, false], [context, .35, false]]) {
|
|
125
|
+
for (const skill of skills) {
|
|
126
|
+
possible += weight; const evidence = evidenceFor(skill);
|
|
127
|
+
if (evidence) { earned += weight * evidence; matched.push(skill); } else if (requiredSkill) missingSkills.push(skill);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const experience = scoreExperience(profile.experienceYears, job?.experienceMinYears ?? extractRequiredExperienceYears(`${buckets.required} ${description}`) ?? undefined);
|
|
131
|
+
const seniority = scoreSeniority(profile.seniority, detectHiringSeniority(title) || detectHiringSeniority(job?.seniority || '') || undefined);
|
|
132
|
+
const scope = scoreScope(`${title} ${buckets.required} ${description}`, profile.raw);
|
|
133
|
+
const educationRequirement = detectDegreeRequirement(`${buckets.required} ${job?.education || ''}`);
|
|
134
|
+
const education = scoreEducation(profile, educationRequirement);
|
|
135
|
+
const skills = possible ? Math.round(earned / possible * 100) : 60;
|
|
136
|
+
const keywordSource = buckets.required || buckets.optional ? `${buckets.required} ${buckets.optional}` : `${title} ${tagText} ${buckets.context.slice(0, 1800)}`;
|
|
137
|
+
const relevance = extractTerms(keywordSource).size ? Math.round(coverage(profile.terms || extractTerms(profile.raw), extractTerms(keywordSource)) * 100) : 60;
|
|
138
|
+
const breakdown = Object.freeze({ skills, experience: experience.score, seniority: seniority.score, scope: scope.score, education: education.score, relevance });
|
|
139
|
+
let fitScore = Math.round(skills * .30 + experience.score * .20 + seniority.score * .20 + scope.score * .15 + education.score * .10 + relevance * .05);
|
|
140
|
+
const usText = `${job?.location || ''} ${title} ${description}`;
|
|
141
|
+
const visaBlocked = (job?.country || '').toUpperCase() === 'US' || detectCountryCodeFromText(usText) === 'US'
|
|
142
|
+
? profile.requiresUsSponsorship === true && isNoSponsorshipRequirement(usText) : false;
|
|
143
|
+
const blockers = visaBlocked ? [Object.freeze({ code: 'visa_sponsorship', label: 'Visa sponsorship unavailable', critical: true })] : [];
|
|
144
|
+
fitScore = Math.max(0, Math.min(100, fitScore));
|
|
145
|
+
const missing = [...blockers.map((item) => item.label), ...missingSkills, ...scope.missing];
|
|
146
|
+
if (experience.gap > 0) missing.push(`${job?.experienceMinYears ?? extractRequiredExperienceYears(`${buckets.required} ${description}`)}+ years experience`);
|
|
147
|
+
if (seniority.gap > 0) missing.push(`${String(detectHiringSeniority(title) || detectHiringSeniority(job?.seniority || '') || '').replace(/^./u, (letter) => letter.toUpperCase())} seniority`);
|
|
148
|
+
if (education.fieldMismatch && educationRequirement.field === 'computer_science') missing.push('Computer Science / related degree');
|
|
149
|
+
else if (education.levelMismatch && educationRequirement.level) missing.push(`${educationRequirement.level} degree`);
|
|
150
|
+
return Object.freeze({ score: blockers.length ? Math.min(fitScore, 49) : fitScore, fitScore, eligible: !blockers.length, blockers: Object.freeze(blockers), breakdown, matched: Object.freeze([...new Set(matched)].slice(0, 12)), missing: Object.freeze([...new Set(missing.filter(Boolean))].slice(0, 12)) });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function hiringAtsScoreColor(score) { return Number(score) >= 75 ? '#34d399' : Number(score) >= 50 ? '#fbbf24' : '#f87171'; }
|
package/src/hiring-skills.d.ts
CHANGED
|
@@ -9,6 +9,12 @@ export interface SkillDefinition extends SkillMeta {
|
|
|
9
9
|
export interface SkillDetail extends SkillMeta {
|
|
10
10
|
name: string;
|
|
11
11
|
}
|
|
12
|
+
export interface SkillMatch {
|
|
13
|
+
canonical: string;
|
|
14
|
+
matched: string;
|
|
15
|
+
matchType: 'exact' | 'alias' | 'normalized' | 'fuzzy';
|
|
16
|
+
confidence: number;
|
|
17
|
+
}
|
|
12
18
|
export const SKILL_CATALOG: SkillDefinition[];
|
|
13
19
|
export const SKILL_KEYWORDS: Readonly<Record<string, readonly string[]>>;
|
|
14
20
|
export const SKILL_META: Readonly<Record<string, SkillMeta>>;
|
|
@@ -18,4 +24,6 @@ export function buildSkillRegex(alias: string): RegExp;
|
|
|
18
24
|
export function canonicalSkillName(value: string): string | undefined;
|
|
19
25
|
export function extractSkillDetails(text: string): SkillDetail[];
|
|
20
26
|
export function extractSkillNames(text: string): string[];
|
|
27
|
+
export function matchSkill(value: unknown, options?: { fuzzy?: boolean; allowAmbiguousExact?: boolean }): SkillMatch | null;
|
|
28
|
+
export function matchSkillCandidates(value: unknown | readonly unknown[], options?: { fuzzy?: boolean; allowAmbiguousExact?: boolean; limit?: number }): readonly SkillMatch[];
|
|
21
29
|
export function getSkillMeta(name: string): SkillMeta | undefined;
|
package/src/hiring-skills.js
CHANGED
|
@@ -279,6 +279,88 @@ for (const { name, aliases } of SKILL_CATALOG) {
|
|
|
279
279
|
for (const alias of aliases) CANONICAL_BY_ALIAS.set(normalizeSkillText(alias), name)
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
+
function levenshtein(left, right) {
|
|
283
|
+
const a = String(left); const b = String(right);
|
|
284
|
+
const row = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
285
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
286
|
+
let previous = row[0]; row[0] = i;
|
|
287
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
288
|
+
const saved = row[j];
|
|
289
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
290
|
+
previous = saved;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return row[b.length];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function fuzzyDistanceLimit(token) {
|
|
297
|
+
if (token.length < 5) return 0;
|
|
298
|
+
if (token.length <= 7) return 1;
|
|
299
|
+
if (token.length <= 12) return 2;
|
|
300
|
+
return 3;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Resolve one deliberately bounded hiring skill candidate. Fuzzy matching is
|
|
305
|
+
* opt-in and compares complete tokens only; it never changes generic lexicon
|
|
306
|
+
* lookup or performs substring matching in ordinary prose.
|
|
307
|
+
*/
|
|
308
|
+
export function matchSkill(value, options = {}) {
|
|
309
|
+
const raw = String(value ?? '').trim();
|
|
310
|
+
if (!raw) return null;
|
|
311
|
+
const normalized = normalizeSkillText(raw);
|
|
312
|
+
const canonical = CANONICAL_BY_ALIAS.get(normalized);
|
|
313
|
+
if (canonical) {
|
|
314
|
+
const definition = SKILL_CATALOG.find((entry) => entry.name === canonical);
|
|
315
|
+
const canonicalNormalized = normalizeSkillText(definition.name);
|
|
316
|
+
// Bare ordinary words are unsafe in free prose. A caller that already has
|
|
317
|
+
// a bounded skills field can opt in explicitly, while generic CV text
|
|
318
|
+
// still requires an unambiguous alias such as `spring framework`.
|
|
319
|
+
if (AMBIGUOUS_CANONICALS.has(canonical) && normalized === canonicalNormalized && options.allowAmbiguousExact !== true) return null;
|
|
320
|
+
const type = raw === definition.name ? 'exact' : canonicalNormalized === normalized ? 'normalized' : 'alias';
|
|
321
|
+
return Object.freeze({ canonical, matched: raw, matchType: type, confidence: type === 'exact' ? 1 : type === 'normalized' ? 0.99 : 0.97 });
|
|
322
|
+
}
|
|
323
|
+
if (options.fuzzy !== true || /\s/u.test(normalized) || !/^[a-z][a-z0-9+#.-]*$/iu.test(normalized)) return null;
|
|
324
|
+
|
|
325
|
+
const candidates = [];
|
|
326
|
+
for (const { name, aliases } of SKILL_CATALOG) {
|
|
327
|
+
if (AMBIGUOUS_CANONICALS.has(name)) continue;
|
|
328
|
+
for (const term of [name, ...aliases]) {
|
|
329
|
+
const candidate = normalizeSkillText(term);
|
|
330
|
+
// Short terms have a very high collision rate (Go/good, C/CSS, REST/
|
|
331
|
+
// restaurant). They are exact-or-alias only.
|
|
332
|
+
if (candidate.length < 5 || /\s/u.test(candidate)) continue;
|
|
333
|
+
const limit = fuzzyDistanceLimit(candidate);
|
|
334
|
+
const distance = levenshtein(normalized, candidate);
|
|
335
|
+
if (distance <= limit) candidates.push({ canonical: name, distance, candidate });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
candidates.sort((a, b) => a.distance - b.distance || a.candidate.length - b.candidate.length || a.canonical.localeCompare(b.canonical));
|
|
339
|
+
const best = candidates[0];
|
|
340
|
+
if (!best) return null;
|
|
341
|
+
return Object.freeze({
|
|
342
|
+
canonical: best.canonical,
|
|
343
|
+
matched: raw,
|
|
344
|
+
matchType: 'fuzzy',
|
|
345
|
+
confidence: Number((1 - best.distance / Math.max(normalized.length, best.candidate.length)).toFixed(2)),
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Match a bounded list or a tokenized skills field, preserving evidence. */
|
|
350
|
+
export function matchSkillCandidates(value, options = {}) {
|
|
351
|
+
const values = Array.isArray(value)
|
|
352
|
+
? value
|
|
353
|
+
: String(value ?? '').split(/[\s,;|/]+/u);
|
|
354
|
+
const byCanonical = new Map();
|
|
355
|
+
for (const candidate of values.slice(0, options.limit ?? 128)) {
|
|
356
|
+
const match = matchSkill(candidate, options);
|
|
357
|
+
if (!match) continue;
|
|
358
|
+
const previous = byCanonical.get(match.canonical);
|
|
359
|
+
if (!previous || match.confidence > previous.confidence) byCanonical.set(match.canonical, match);
|
|
360
|
+
}
|
|
361
|
+
return Object.freeze([...byCanonical.values()].sort((a, b) => b.confidence - a.confidence || a.canonical.localeCompare(b.canonical)));
|
|
362
|
+
}
|
|
363
|
+
|
|
282
364
|
export function canonicalSkillName(value) {
|
|
283
365
|
const normalized = normalizeSkillText(value)
|
|
284
366
|
return CANONICAL_BY_ALIAS.get(normalized) ?? extractSkillNames(normalized)[0]
|
package/src/hiring-temporal.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const HIRING_MONTHS = Object.freeze({
|
|
2
|
-
январь: 0, января: 0, february: 1, февраль: 1, февраля: 1,
|
|
2
|
+
январь: 0, января: 0, january: 0, jan: 0, february: 1, feb: 1, февраль: 1, февраля: 1,
|
|
3
3
|
март: 2, марта: 2, april: 3, апрель: 3, апреля: 3,
|
|
4
4
|
may: 4, май: 4, мая: 4, june: 5, июнь: 5, июня: 5,
|
|
5
5
|
july: 6, июль: 6, июля: 6, august: 7, август: 7, августа: 7,
|
package/src/housing-address.d.ts
CHANGED
|
@@ -4,7 +4,20 @@ export type HousingAddressParts = Readonly<{
|
|
|
4
4
|
houseNumber: string | null;
|
|
5
5
|
building: string | null;
|
|
6
6
|
confidence: number;
|
|
7
|
+
unit?: string;
|
|
8
|
+
level?: string;
|
|
9
|
+
entrance?: string;
|
|
10
|
+
staircase?: string;
|
|
7
11
|
district?: string;
|
|
12
|
+
metro?: string;
|
|
13
|
+
mahalla?: string;
|
|
14
|
+
geoEntities?: Readonly<Partial<Record<'street' | 'district' | 'metro' | 'mahalla' | 'residentialComplex', Readonly<{
|
|
15
|
+
id: string;
|
|
16
|
+
canonical: string;
|
|
17
|
+
type: string;
|
|
18
|
+
country: string;
|
|
19
|
+
parentId?: string;
|
|
20
|
+
}>>>>;
|
|
8
21
|
quarter?: Readonly<{
|
|
9
22
|
number: number;
|
|
10
23
|
suffix: string;
|
|
@@ -17,9 +30,42 @@ export function parseHousingAddress(
|
|
|
17
30
|
allowBare?: boolean;
|
|
18
31
|
allowDelimitedBare?: boolean;
|
|
19
32
|
knownStreet?: string | null;
|
|
33
|
+
knownStreets?: readonly string[];
|
|
34
|
+
country?: string | null;
|
|
35
|
+
city?: string | null;
|
|
36
|
+
resolveGeoEntity?: ((input: Readonly<{ country: string; city?: string; type: string; canonical: string }>) => Readonly<{
|
|
37
|
+
id: string;
|
|
38
|
+
canonicalName?: string;
|
|
39
|
+
canonical?: string;
|
|
40
|
+
type?: string;
|
|
41
|
+
country?: string;
|
|
42
|
+
parentId?: string;
|
|
43
|
+
}> | null | undefined);
|
|
20
44
|
}>,
|
|
21
45
|
): HousingAddressParts;
|
|
22
46
|
|
|
47
|
+
export function resolveHousingAddressGeoEntities(
|
|
48
|
+
parts: HousingAddressParts | null | undefined,
|
|
49
|
+
options: Readonly<{
|
|
50
|
+
country?: string | null;
|
|
51
|
+
city?: string | null;
|
|
52
|
+
resolveGeoEntity?: ((input: Readonly<{ country: string; city?: string; type: string; canonical: string }>) => Readonly<{
|
|
53
|
+
id: string;
|
|
54
|
+
canonicalName?: string;
|
|
55
|
+
canonical?: string;
|
|
56
|
+
type?: string;
|
|
57
|
+
country?: string;
|
|
58
|
+
parentId?: string;
|
|
59
|
+
}> | null | undefined);
|
|
60
|
+
}>,
|
|
61
|
+
): Readonly<Partial<Record<'street' | 'district' | 'metro' | 'mahalla' | 'residentialComplex', Readonly<{
|
|
62
|
+
id: string;
|
|
63
|
+
canonical: string;
|
|
64
|
+
type: string;
|
|
65
|
+
country: string;
|
|
66
|
+
parentId?: string;
|
|
67
|
+
}>>>>;
|
|
68
|
+
|
|
23
69
|
export function composeHousingAddress(parts?: Readonly<{
|
|
24
70
|
street?: unknown;
|
|
25
71
|
houseNumber?: unknown;
|