@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 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.6.17",
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
- return Object.freeze(spans);
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'; }
@@ -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;
@@ -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]
@@ -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,
@@ -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;