218 lines
8.6 KiB
JavaScript
218 lines
8.6 KiB
JavaScript
/* HR Candidate Matcher — AI Adapter (Demo Mode) */
|
||
/* global window */
|
||
(function () {
|
||
'use strict';
|
||
|
||
var APP = window.HRMatcher = window.HRMatcher || {};
|
||
|
||
var SKILLS = APP.SKILL_DICTIONARY;
|
||
var TEMPLATES = APP.INTERVIEW_TEMPLATES;
|
||
|
||
var EXPERIENCE_PATTERN = /(?:опыт(?:\s+работы)?[\s:]*(?:от\s+)?(\d+)\s*(?:год|лет|года))/i;
|
||
var EXPERIENCE_PATTERN2 = /(?:от\s+(\d+)\s*(?:год|лет|года)\s*опыта)/i;
|
||
var EXPERIENCE_PATTERN3 = /(?:стаж\s+(?:от\s+)?(\d+)\s*(?:год|лет|года))/i;
|
||
var EXPERIENCE_PATTERN4 = /(\d+)\s*(?:год|лет|года)\s*(?:опыта|стажа)/i;
|
||
|
||
var REQUIREMENT_PATTERNS = [
|
||
/(?:знания?\s+|владение\s+|умени[ея]\s+(?:работать\s+)?(?:с\s+)?|опыт\s+(?:работы\s+)?(?:с\s+)?(?:в\s+)?)((?:[A-Za-zА-Яа-яЁё0-9.]+\s*){1,4})/gi,
|
||
/(?:требуется|необходимо|обязательно|нужно)[\s:]+((?:[A-Za-zА-Яа-яЁё0-9.]+\s*){1,4})/gi
|
||
];
|
||
|
||
var NEGATION_PATTERN = /(?:без\s+|нет\s+|не\s+(?:имеет?\s+)?|отсутств(?:ие|ует|уют)\s+|не\s+требует(?:ся)?)\s*/i;
|
||
|
||
function normalizeText(text) {
|
||
return (text || '').toLowerCase().replace(/\s+/g, ' ').trim();
|
||
}
|
||
|
||
function detectSkillType(label) {
|
||
var lower = normalizeText(label);
|
||
if (/\b(опыт|стаж|лет\s+работ)\b/.test(lower)) return 'experience';
|
||
if (/\b(analytics|analyt|метрик|аналитик|отчёт|отчет|тестирован|a\/b)\b/.test(lower)) return 'tool';
|
||
if (/\b(ads|реклам|campaign|кампани|target|таргет)\b/.test(lower)) return 'tool';
|
||
return 'skill';
|
||
}
|
||
|
||
APP.aiAdapter = {
|
||
isDemoMode: true,
|
||
|
||
extractRequirements: function (vacancyText) {
|
||
var text = vacancyText || '';
|
||
var criteria = [];
|
||
var seen = {};
|
||
|
||
Object.keys(SKILLS).forEach(function (key) {
|
||
var skill = SKILLS[key];
|
||
var allTerms = [key].concat(skill.synonyms);
|
||
var found = false;
|
||
for (var i = 0; i < allTerms.length; i++) {
|
||
var term = allTerms[i];
|
||
var regex = new RegExp('\\b' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'i');
|
||
if (regex.test(text)) {
|
||
found = true;
|
||
break;
|
||
}
|
||
}
|
||
if (found && !seen[key]) {
|
||
seen[key] = true;
|
||
var isDesired = /(?:плюс|желательно|будет\s+плюсом|приветствуется|желател)/i.test(
|
||
text.substring(Math.max(0, text.toLowerCase().indexOf(key) - 50), text.toLowerCase().indexOf(key) + key.length + 50)
|
||
);
|
||
criteria.push(APP.createCriterion(
|
||
skill.label,
|
||
!isDesired,
|
||
isDesired ? 2 : 3,
|
||
skill.synonyms.slice(0, 3)
|
||
));
|
||
}
|
||
});
|
||
|
||
var expMatch = text.match(EXPERIENCE_PATTERN) || text.match(EXPERIENCE_PATTERN2) ||
|
||
text.match(EXPERIENCE_PATTERN3) || text.match(EXPERIENCE_PATTERN4);
|
||
if (expMatch) {
|
||
var years = parseInt(expMatch[1], 10);
|
||
if (years > 0 && years <= 20 && !seen['experience_years']) {
|
||
seen['experience_years'] = true;
|
||
criteria.push(APP.createCriterion(
|
||
'Опыт работы от ' + years + ' лет',
|
||
true,
|
||
4,
|
||
['стаж от ' + years, years + ' лет опыта', 'опыт ' + years + ' года']
|
||
));
|
||
}
|
||
}
|
||
|
||
var lines = text.split('\n');
|
||
for (var li = 0; li < lines.length; li++) {
|
||
var line = lines[li].trim();
|
||
if (/^[-–—•]\s+/.test(line)) {
|
||
var content = line.replace(/^[-–—•]\s+/, '').trim();
|
||
if (content.length > 5 && content.length < 100) {
|
||
var lowerContent = normalizeText(content);
|
||
var alreadyMatched = false;
|
||
Object.keys(SKILLS).forEach(function (key) {
|
||
var allTerms = [key].concat(SKILLS[key].synonyms);
|
||
for (var ti = 0; ti < allTerms.length; ti++) {
|
||
if (lowerContent.indexOf(normalizeText(allTerms[ti])) !== -1) {
|
||
alreadyMatched = true;
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
if (!alreadyMatched && /(?:знани|владе|уме|опыт|способн|знаком)/i.test(content)) {
|
||
var cleanLabel = content.replace(/^(?:знание|владение|умение|опыт|знакомство)\s+(?:с\s+)?/i, '').trim();
|
||
if (cleanLabel.length >= 3 && !seen[normalizeText(cleanLabel)]) {
|
||
seen[normalizeText(cleanLabel)] = true;
|
||
criteria.push(APP.createCriterion(cleanLabel, true, 3, []));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return criteria;
|
||
},
|
||
|
||
matchCriterion: function (criterion, resumeText) {
|
||
var label = criterion.label || '';
|
||
var synonyms = criterion.synonyms || [];
|
||
var allTerms = [label].concat(synonyms).map(function (t) { return t.trim(); }).filter(Boolean);
|
||
var resumeLower = normalizeText(resumeText);
|
||
var evidence = [];
|
||
|
||
for (var i = 0; i < allTerms.length; i++) {
|
||
var term = allTerms[i];
|
||
var termLower = normalizeText(term);
|
||
var idx = resumeLower.indexOf(termLower);
|
||
if (idx !== -1) {
|
||
var contextStart = Math.max(0, idx - 40);
|
||
var contextEnd = Math.min(resumeText.length, idx + term.length + 40);
|
||
var fragment = resumeText.substring(contextStart, contextEnd).trim();
|
||
if (contextStart > 0) fragment = '…' + fragment;
|
||
if (contextEnd < resumeText.length) fragment = fragment + '…';
|
||
|
||
var beforeText = resumeText.substring(Math.max(0, idx - 30), idx);
|
||
if (NEGATION_PATTERN.test(beforeText)) {
|
||
continue;
|
||
}
|
||
|
||
evidence.push(APP.createEvidence(fragment, idx));
|
||
}
|
||
}
|
||
|
||
if (evidence.length > 0) {
|
||
return APP.createMatchResult(criterion.id, APP.STATUS.CONFIRMED, evidence, '');
|
||
}
|
||
|
||
var resumeSentences = resumeText.split(/[.!?\n]+/).map(function (s) { return s.trim(); }).filter(Boolean);
|
||
var unclear = false;
|
||
for (var si = 0; si < resumeSentences.length; si++) {
|
||
var sentence = normalizeText(resumeSentences[si]);
|
||
for (var ti = 0; ti < allTerms.length; ti++) {
|
||
var tLower = normalizeText(allTerms[ti]);
|
||
var words = tLower.split(/\s+/);
|
||
var matchedWords = 0;
|
||
for (var wi = 0; wi < words.length; wi++) {
|
||
if (words[wi].length >= 3 && sentence.indexOf(words[wi]) !== -1) {
|
||
matchedWords++;
|
||
}
|
||
}
|
||
if (words.length > 1 && matchedWords >= words.length * 0.5) {
|
||
var sentIdx = resumeText.toLowerCase().indexOf(resumeSentences[si].toLowerCase());
|
||
if (sentIdx !== -1) {
|
||
evidence.push(APP.createEvidence(resumeSentences[si], sentIdx));
|
||
}
|
||
unclear = true;
|
||
break;
|
||
}
|
||
}
|
||
if (unclear) break;
|
||
}
|
||
|
||
if (unclear) {
|
||
return APP.createMatchResult(criterion.id, APP.STATUS.UNCLEAR, evidence, 'Требуется уточнение');
|
||
}
|
||
|
||
return APP.createMatchResult(criterion.id, APP.STATUS.NOT_FOUND, [], '');
|
||
},
|
||
|
||
generateQuestions: function (criterion, matchResult) {
|
||
var type = detectSkillType(criterion.label);
|
||
var templates = TEMPLATES[type] || TEMPLATES.skill;
|
||
var questions = [];
|
||
var label = criterion.label;
|
||
|
||
for (var i = 0; i < templates.length; i++) {
|
||
questions.push(templates[i].replace(/\{skill\}/g, label));
|
||
}
|
||
|
||
if (matchResult && matchResult.status === APP.STATUS.UNCLEAR && matchResult.evidence.length > 0) {
|
||
questions.unshift('В резюме упоминается «' + label + '», но контекст неясен. Можете уточнить ваш уровень и примеры использования?');
|
||
}
|
||
|
||
return questions;
|
||
},
|
||
|
||
detectSkillType: detectSkillType,
|
||
|
||
computeCoverage: function (criteria, matchResults) {
|
||
if (!criteria || criteria.length === 0) return 0;
|
||
var totalWeight = 0;
|
||
var confirmedWeight = 0;
|
||
var resultMap = {};
|
||
(matchResults || []).forEach(function (r) { resultMap[r.criterionId] = r; });
|
||
|
||
for (var i = 0; i < criteria.length; i++) {
|
||
var c = criteria[i];
|
||
totalWeight += c.weight;
|
||
var result = resultMap[c.id];
|
||
if (result && result.status === APP.STATUS.CONFIRMED) {
|
||
confirmedWeight += c.weight;
|
||
}
|
||
}
|
||
|
||
if (totalWeight === 0) return 0;
|
||
return Math.round((confirmedWeight / totalWeight) * 100);
|
||
}
|
||
};
|
||
})();
|