408 lines
16 KiB
JavaScript
408 lines
16 KiB
JavaScript
/**
|
||
* AI Adapter - демонстрационный режим
|
||
* Детерминированная логика на словарях и шаблонах без реальной ML-модели
|
||
*/
|
||
|
||
const AISKILLS = {
|
||
frontend: ['javascript', 'js', 'react', 'vue', 'angular', 'typescript', 'ts', 'html', 'css', 'sass', 'less', 'webpack', 'vite', 'gulp', 'npm', 'yarn', 'git', 'redux', 'mobx', 'next.js', 'nuxt', 'tailwind', 'bootstrap', 'jquery', 'ajax', 'rest', 'graphql', 'api', 'dom', 'es6', 'ecmascript'],
|
||
backend: ['node.js', 'nodejs', 'python', 'java', 'php', 'go', 'golang', 'rust', 'ruby', 'c#', 'csharp', '.net', 'dotnet', 'django', 'flask', 'fastapi', 'spring', 'laravel', 'express', 'nestjs', 'fastify', 'postgresql', 'mysql', 'mongodb', 'redis', 'elasticsearch', 'kafka', 'rabbitmq', 'docker', 'kubernetes', 'aws', 'azure', 'gcp', 'nginx', 'apache', 'linux', 'bash', 'shell', 'ci/cd', 'gitlab ci', 'github actions', 'terraform', 'ansible'],
|
||
database: ['sql', 'postgresql', 'postgres', 'mysql', 'mariadb', 'mongodb', 'mongo', 'redis', 'elasticsearch', 'cassandra', 'oracle', 'mssql', 'sqlite', 'typeorm', 'sequelize', 'prisma', 'mongoose', 'sqlalchemy', 'hibernate', 'entity framework'],
|
||
devops: ['docker', 'kubernetes', 'k8s', 'jenkins', 'gitlab ci', 'github actions', 'circleci', 'travis', 'terraform', 'ansible', 'puppet', 'chef', 'aws', 'azure', 'gcp', 'linux', 'bash', 'shell', 'nginx', 'apache', 'prometheus', 'grafana', 'elk', 'splunk', 'helm', 'istio', 'vault', 'consul'],
|
||
mobile: ['ios', 'android', 'swift', 'kotlin', 'react native', 'flutter', 'dart', 'xamarin', 'cordova', 'ionic', 'mobile ui', 'app store', 'google play'],
|
||
qa: ['testing', 'test', 'qa', 'automation', 'selenium', 'cypress', 'playwright', 'jest', 'mocha', 'chai', 'pytest', 'unittest', 'junit', 'testng', 'postman', 'swagger', 'api testing', 'load testing', 'jmeter', 'manual testing', 'regression', 'smoke', 'integration testing'],
|
||
soft: ['communication', 'teamwork', 'leadership', 'management', 'agile', 'scrum', 'kanban', 'jira', 'confluence', 'mentoring', 'code review', 'documentation', 'english', 'russian', 'kazakh', 'problem solving', 'analytical thinking', 'time management']
|
||
};
|
||
|
||
const SYNONYMS = {
|
||
'javascript': ['js', 'es6', 'ecmascript', 'vanilla js'],
|
||
'typescript': ['ts', 'typed javascript'],
|
||
'react': ['react.js', 'reactjs', 'react jsx'],
|
||
'vue': ['vue.js', 'vuejs', 'vue 3', 'vue 2'],
|
||
'angular': ['angular 2+', 'angularjs'],
|
||
'node.js': ['nodejs', 'node', 'backend javascript'],
|
||
'python': ['python 3', 'django', 'flask', 'fastapi'],
|
||
'postgresql': ['postgres', 'psql'],
|
||
'mongodb': ['mongo', 'nosql'],
|
||
'docker': ['containers', 'containerization'],
|
||
'kubernetes': ['k8s', 'orchestration'],
|
||
'git': ['version control', 'vcs', 'github', 'gitlab', 'bitbucket'],
|
||
'css': ['styles', 'cascading style sheets', 'scss', 'sass', 'less'],
|
||
'html': ['markup', 'semantic html'],
|
||
'rest': ['rest api', 'http api', 'web api'],
|
||
'graphql': ['gql', 'graphql api'],
|
||
'aws': ['amazon web services', 'ec2', 's3', 'lambda'],
|
||
'linux': ['unix', 'ubuntu', 'centos', 'debian'],
|
||
'agile': ['scrum', 'kanban', 'sprint', 'iterative'],
|
||
'communication': ['коммуникабельность', 'общение', 'презентация'],
|
||
'teamwork': ['командная работа', 'коллаборация'],
|
||
'leadership': ['лидерство', 'управление', 'менторство']
|
||
};
|
||
|
||
const PATTERNS = {
|
||
experience: [
|
||
/опыт[\s\S]*?(\d+)\s*(?:лет|года|год)/gi,
|
||
/(\d+)\s*(?:лет|года|год)\s*опыта/gi,
|
||
/стаж[\s\S]*?(\d+)\s*(?:лет|года|год)/gi,
|
||
/working[\s\S]*?(\d+)\s*(?:years?|year)/gi,
|
||
/(\d+)\s*(?:years?|year)\s*of\s*experience/gi
|
||
],
|
||
skill: [
|
||
/знание\s+([а-яёa-z0-9+\-.#]+)/gi,
|
||
/владе[ею]\s+([а-яёa-z0-9+\-.#]+)/gi,
|
||
/опыт\s+работы\s+с\s+([а-яёa-z0-9+\-.#]+)/gi,
|
||
/уверенное\s+владение\s+([а-яёa-z0-9+\-.#]+)/gi,
|
||
/профессиональное\s+владение\s+([а-яёa-z0-9+\-.#]+)/gi,
|
||
/хорошее\s+знание\s+([а-яёa-z0-9+\-.#]+)/gi,
|
||
/знаком[а-я]*\s+с\s+([а-яёa-z0-9+\-.#]+)/gi,
|
||
/(?:skill|proficient|familiar)\s+(?:in|with)\s+([а-яёa-z0-9+\-.#]+)/gi,
|
||
/experience\s+(?:with|in)\s+([а-яёa-z0-9+\-.#]+)/gi
|
||
],
|
||
education: [
|
||
/(?:высшее|бакалавр|магистр|кандидат|доктор)[\s\S]*?(?:образование|ученая степень)/gi,
|
||
/(?:bachelor|master|phd|doctor)[\s\S]*?(?:degree|education)/gi,
|
||
/окончил[\s\S]*?(?:университет|институт|академия|колледж)/gi
|
||
]
|
||
};
|
||
|
||
const NEGATION_PATTERNS = [
|
||
/без\s+опыта/gi,
|
||
/не\s+(?:имею|имеет|владе[ею]|зна[ею]ю)/gi,
|
||
/отсутствие\s+(?:опыта|знаний)/gi,
|
||
/не\s+работал/gi,
|
||
/never\s+(?:used|worked)/gi,
|
||
/no\s+experience/gi,
|
||
/without\s+experience/gi
|
||
];
|
||
|
||
function generate(kind, payload) {
|
||
switch (kind) {
|
||
case 'extractCriteria':
|
||
return extractCriteria(payload.vacancyText);
|
||
case 'matchResume':
|
||
return matchResume(payload.criteria, payload.resumeText);
|
||
case 'generateQuestions':
|
||
return generateQuestions(payload.unmatchedCriteria);
|
||
default:
|
||
return { error: 'Unknown operation' };
|
||
}
|
||
}
|
||
|
||
function extractCriteria(text) {
|
||
if (!text || typeof text !== 'string') {
|
||
return { criteria: [], error: 'Пустой текст вакансии' };
|
||
}
|
||
|
||
const criteria = [];
|
||
const lowerText = text.toLowerCase();
|
||
|
||
for (const [category, skills] of Object.entries(AISKILLS)) {
|
||
for (const skill of skills) {
|
||
const regex = new RegExp(`\\b${escapeRegex(skill)}\\b`, 'gi');
|
||
const matches = lowerText.match(regex);
|
||
if (matches && matches.length > 0) {
|
||
const existing = criteria.find(c => c.label.toLowerCase() === skill);
|
||
if (!existing) {
|
||
const isRequired = checkIfRequired(text, skill);
|
||
criteria.push({
|
||
id: generateId(skill),
|
||
label: capitalizeFirst(skill),
|
||
required: isRequired,
|
||
weight: isRequired ? 5 : 3,
|
||
synonyms: SYNONYMS[skill.toLowerCase()] || [],
|
||
category
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const expMatches = extractExperience(text);
|
||
if (expMatches.length > 0) {
|
||
criteria.push({
|
||
id: 'experience-years',
|
||
label: 'Опыт работы (лет)',
|
||
required: true,
|
||
weight: 5,
|
||
synonyms: ['стаж', 'years of experience', 'working experience'],
|
||
category: 'experience',
|
||
minYears: expMatches[0]
|
||
});
|
||
}
|
||
|
||
const eduMatches = lowerText.match(PATTERNS.education[0]);
|
||
if (eduMatches && eduMatches.length > 0) {
|
||
criteria.push({
|
||
id: 'education',
|
||
label: 'Образование',
|
||
required: true,
|
||
weight: 4,
|
||
synonyms: ['высшее образование', 'degree', 'bachelor', 'master'],
|
||
category: 'education'
|
||
});
|
||
}
|
||
|
||
if (criteria.length === 0) {
|
||
criteria.push({
|
||
id: 'custom-1',
|
||
label: 'Профессиональные навыки',
|
||
required: true,
|
||
weight: 5,
|
||
synonyms: [],
|
||
category: 'general'
|
||
});
|
||
}
|
||
|
||
return { criteria, vacancyText: text };
|
||
}
|
||
|
||
function matchResume(criteria, resumeText) {
|
||
if (!resumeText || typeof resumeText !== 'string') {
|
||
return { results: [], coverage: 0 };
|
||
}
|
||
|
||
const results = criteria.map(criterion => {
|
||
const match = findSkillInResume(criterion, resumeText);
|
||
return {
|
||
criterionId: criterion.id,
|
||
label: criterion.label,
|
||
required: criterion.required,
|
||
weight: criterion.weight,
|
||
status: match.status,
|
||
evidence: match.evidence,
|
||
note: ''
|
||
};
|
||
});
|
||
|
||
const totalWeight = results.reduce((sum, r) => sum + r.weight, 0);
|
||
const confirmedWeight = results
|
||
.filter(r => r.status === 'confirmed')
|
||
.reduce((sum, r) => sum + r.weight, 0);
|
||
|
||
const coverage = totalWeight > 0 ? Math.round((confirmedWeight / totalWeight) * 100) : 0;
|
||
|
||
return { results, coverage };
|
||
}
|
||
|
||
function generateQuestions(unmatchedCriteria) {
|
||
if (!unmatchedCriteria || unmatchedCriteria.length === 0) {
|
||
return { questions: [] };
|
||
}
|
||
|
||
const questions = unmatchedCriteria.map(criterion => {
|
||
const questionTemplates = getQuestionTemplates(criterion.category);
|
||
const template = questionTemplates[Math.floor(Math.random() * questionTemplates.length)];
|
||
|
||
return {
|
||
criterionId: criterion.criterionId,
|
||
label: criterion.label,
|
||
status: criterion.status,
|
||
question: template.replace('{skill}', criterion.label.toLowerCase()),
|
||
followUp: getFollowUpQuestions(criterion.status)
|
||
};
|
||
});
|
||
|
||
return { questions };
|
||
}
|
||
|
||
function findSkillInResume(criterion, text) {
|
||
const lowerText = text.toLowerCase();
|
||
const searchTerms = [criterion.label.toLowerCase(), ...(criterion.synonyms || [])];
|
||
|
||
for (const term of searchTerms) {
|
||
const regex = new RegExp(`\\b${escapeRegex(term)}\\b`, 'gi');
|
||
const matches = lowerText.match(regex);
|
||
|
||
if (matches && matches.length > 0) {
|
||
if (isNegated(text, term)) {
|
||
continue;
|
||
}
|
||
|
||
const evidence = extractEvidence(text, term, matches.length);
|
||
return {
|
||
status: 'confirmed',
|
||
evidence: [evidence]
|
||
};
|
||
}
|
||
}
|
||
|
||
if (criterion.required) {
|
||
return {
|
||
status: 'clarify',
|
||
evidence: []
|
||
};
|
||
}
|
||
|
||
return {
|
||
status: 'notfound',
|
||
evidence: []
|
||
};
|
||
}
|
||
|
||
function extractEvidence(text, searchTerm, matchCount) {
|
||
const lowerText = text.toLowerCase();
|
||
const index = lowerText.indexOf(searchTerm.toLowerCase());
|
||
|
||
if (index === -1) return '';
|
||
|
||
const start = Math.max(0, index - 50);
|
||
const end = Math.min(text.length, index + searchTerm.length + 50);
|
||
|
||
let snippet = text.substring(start, end);
|
||
if (start > 0) snippet = '...' + snippet;
|
||
if (end < text.length) snippet = snippet + '...';
|
||
|
||
return snippet.trim();
|
||
}
|
||
|
||
function isNegated(text, searchTerm) {
|
||
const lowerText = text.toLowerCase();
|
||
const index = lowerText.indexOf(searchTerm.toLowerCase());
|
||
|
||
if (index === -1) return false;
|
||
|
||
const contextStart = Math.max(0, index - 30);
|
||
const context = text.substring(contextStart, index + searchTerm.length);
|
||
|
||
for (const pattern of NEGATION_PATTERNS) {
|
||
pattern.lastIndex = 0;
|
||
if (pattern.test(context)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
function checkIfRequired(text, skill) {
|
||
const lowerText = text.toLowerCase();
|
||
const requiredPatterns = [
|
||
new RegExp(`обязательно.*${escapeRegex(skill)}`, 'gi'),
|
||
new RegExp(`требуется.*${escapeRegex(skill)}`, 'gi'),
|
||
new RegExp(`необходимо.*${escapeRegex(skill)}`, 'gi'),
|
||
new RegExp(`must have.*${escapeRegex(skill)}`, 'gi'),
|
||
new RegExp(`required.*${escapeRegex(skill)}`, 'gi')
|
||
];
|
||
|
||
for (const pattern of requiredPatterns) {
|
||
if (pattern.test(lowerText)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
const niceToHavePatterns = [
|
||
new RegExp(`будет\\s*(\\+|плюсом|преимуществом).*${escapeRegex(skill)}`, 'gi'),
|
||
new RegExp(`nice\\s*to\\s*have.*${escapeRegex(skill)}`, 'gi'),
|
||
new RegExp(`preferred.*${escapeRegex(skill)}`, 'gi')
|
||
];
|
||
|
||
for (const pattern of niceToHavePatterns) {
|
||
if (pattern.test(lowerText)) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
function extractExperience(text) {
|
||
for (const pattern of PATTERNS.experience) {
|
||
const matches = text.match(pattern);
|
||
if (matches && matches.length > 0) {
|
||
const numberMatch = matches[0].match(/(\d+)/);
|
||
if (numberMatch) {
|
||
return [parseInt(numberMatch[1], 10)];
|
||
}
|
||
}
|
||
}
|
||
return [];
|
||
}
|
||
|
||
function getQuestionTemplates(category) {
|
||
const templates = {
|
||
frontend: [
|
||
'Расскажите о проекте, где вы использовали {skill}. С какими сложностями столкнулись?',
|
||
'Как вы применяете {skill} в реальной разработке? Приведите пример.',
|
||
'Опишите ваш подход к решению задач с использованием {skill}.'
|
||
],
|
||
backend: [
|
||
'Как вы проектировали архитектуру backend с использованием {skill}?',
|
||
'Расскажите о производительности и масштабируемости при работе с {skill}.',
|
||
'С какими проблемами вы сталкивались при использовании {skill} в production?'
|
||
],
|
||
database: [
|
||
'Как вы оптимизировали запросы в {skill}?',
|
||
'Опишите схему данных, которую вы проектировали с использованием {skill}.',
|
||
'Как вы обеспечивали целостность данных в {skill}?'
|
||
],
|
||
devops: [
|
||
'Опишите ваш CI/CD пайплайн с использованием {skill}.',
|
||
'Как вы мониторили и поддерживали инфраструктуру с {skill}?',
|
||
'Расскажите о развёртывании проектов с помощью {skill}.'
|
||
],
|
||
mobile: [
|
||
'Расскажите о опубликованном приложении, где вы использовали {skill}.',
|
||
'Как вы оптимизировали производительность мобильного приложения на {skill}?',
|
||
'С какими особенностями платформ вы сталкивались при работе с {skill}?'
|
||
],
|
||
qa: [
|
||
'Опишите стратегию тестирования, которую вы применяли с {skill}.',
|
||
'Как вы автоматизировали тестирование с помощью {skill}?',
|
||
'Расскажите о найденных критических багах благодаря {skill}.'
|
||
],
|
||
soft: [
|
||
'Приведите пример успешной командной работы, где вы проявили {skill}.',
|
||
'Как вы применяете {skill} в ежедневной работе?',
|
||
'Опишите ситуацию, где {skill} помог решить сложную задачу.'
|
||
],
|
||
experience: [
|
||
'Расскажите о вашем опыте работы с {skill}. Какие проекты вы реализовали?',
|
||
'Как развивались ваши навыки в {skill} за последние годы?',
|
||
'Что нового вы изучили в области {skill} за последний год?'
|
||
],
|
||
education: [
|
||
'Как ваше образование связано с этой позицией?',
|
||
'Какие курсы или сертификации вы проходили по направлению {skill}?',
|
||
'Как вы продолжаете развиваться в профессиональной области?'
|
||
],
|
||
general: [
|
||
'Расскажите подробнее о вашем опыте работы с {skill}.',
|
||
'Как вы применяли {skill} в предыдущих проектах?',
|
||
'Какие задачи вы решали с помощью {skill}?'
|
||
]
|
||
};
|
||
|
||
return templates[category] || templates.general;
|
||
}
|
||
|
||
function getFollowUpQuestions(status) {
|
||
if (status === 'clarify') {
|
||
return [
|
||
'Уточните уровень владения этим навыком.',
|
||
'Когда вы в последний раз использовали этот навык на практике?'
|
||
];
|
||
}
|
||
return [
|
||
'Можете привести конкретный пример использования?',
|
||
'Какой результат был достигнут?'
|
||
];
|
||
}
|
||
|
||
function generateId(text) {
|
||
return text
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, '-')
|
||
.replace(/^-|-$/g, '');
|
||
}
|
||
|
||
function capitalizeFirst(text) {
|
||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||
}
|
||
|
||
function escapeRegex(string) {
|
||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
}
|
||
|
||
if (typeof module !== 'undefined' && module.exports) {
|
||
module.exports = { generate, AISKILLS, SYNONYMS };
|
||
}
|