
Когда в день вам присылают 400 резюме на одну вакансию, «посмотреть всех» физически не получится. Зато это выглядит прямо как задача под автоматизацию!
Итак, у меня есть вакансия и огромная пачка PDF-файлов с резюме. Надо понять, в каких из них действительно подтверждаются нужные компетенции, где информации не хватает и кого стоит посмотреть в первую очередь. Мы не будем автоматизировать сам найм или отправку отказов. В кейсе ниже LLM будет составлять техническую выжимку и помогать разобрать очередь. Финальное решение останется за человеком.
По идее можно было бы открыть любую ИИшницу, загрузить туда пачку резюме и собрать результаты. Но это не автоматизация, а ерунда. Поэтому мне нужен API, который можно подключить и встроить в собственный процесс проверки резюме. Для меня тут важен именно уровень абстракции. Я не хочу искать GPU, скачивать веса модели, подбирать версию CUDA, поднимать vLLM, рассчитывать, влезет ли модель в видеопамять, настраивать масштабирование, мониторить отдельный ML-зоопарк и выяснять, почему после обновления драйвера все снова упало.
Статью написал Роман Шубин, CTO и автор Telegram-канала Bash Days.
Идея и подготовка
Да, у меня есть домашний LLM-стек, собранный до всех этих кризисов с памятью, но это решение мне не подошло. Оно требует постоянно держать эту махину включенной, а я частенько нахожусь в перелетах и сильно тревожусь, если оставил дома «включенный утюг». Поэтому выбор пал на FMC. Все работает в одном из московских дата-центров Selectel, не потребляет мое электричество и доступно в любое время и в любом месте. То что нужно! Self-hosted — это хорошо, но под некоторые задачи все же лучше выбирать инструмент, который будет комфортнее.
Интеграция модели в мой проект и бизнес-логика остаются на моей стороне, сервис разделяет эти зоны. То есть Selectel дает мне работающий эндпоинт, а я уже решаю, как использовать его в своем коде.
На момент моего тестирования, FMC находится на стадии public preview. Инференс-сервисы работают синхронно, а загрузка собственных моделей в каталог пока не поддерживается.
Наша задача — взять описание конкретной вакансии, извлечь текст из резюме, проверить наличие подтвержденного опыта по заранее заданным критериям и собрать понятный отчет для HR. Важно — не улучшать резюме, не оценивать человека, не анализировать фотографию, возраст или семейное положение. А именно сопоставить профессиональный опыт из документа с требованиями конкретной вакансии.
В результате вместо папки с 400 резюме я хочу получить таблицу:
|
Кандидат |
Соответствие |
Итог |
|
resume-001.pdf |
88/100 |
соответствует профилю |
|
resume-002.pdf |
61/100 |
требуется ручная проверка |
|
resume-003.pdf |
34/100 |
недостаточно подтверждений |
|
resume-004.pdf |
42/100 |
много ключевых слов, мало доказательств |
|
resume-005.pdf |
84/100 |
соответствует профилю |
При этом по каждому баллу должны быть видны доказательства из исходного текста:
Критерий: CI/CD
Уровень: 4 из 4Доказательства:
Разработал GitLab CI pipeline для сборки, тестирования и развертывания 18 сервисов.
Сократил время деплоя с 35 до 12 минут.
Если доказательства нет, модель должна честно написать: «В тексте резюме подтверждение не найдено», а не додумывать опыт кандидата.
Для анализа резюме подойдет обычная text-to-text-модель. На вход отправляем вакансию, критерии и извлеченный текст, на выходе ожидаем структурированный JSON. Никакие model tools для этого не потребуются. Последовательностью действий управляет Python:

Модель не должна сама открывать файл, выполнять команды или менять статус кандидата в кадровой системе. Она решает только одну задачу — делает структурированную оценку по заданным критериям на основе текста из резюме.
Для эксперимента я взял t-tech/T-pro-it-2.1. Модель доступна в актуальном каталоге FMC вместе с T-lite, Qwen, Gemma и другими text-to-text-моделями.
Почему именно она? Потому что входные данные у меня русскоязычные. При этом в резюме встречаются английские названия технологий, команды, аббревиатуры и описание технических проектов. От модели мне нужен не творческий текст, а строгое следование инструкции:
-
обработать заранее заданный список критериев,
-
не придумывать отсутствующий опыт,
-
привести доказательства,
-
вернуть валидный JSON,
-
не рассчитывать итоговый балл самостоятельно.
Последний пункт тут очень важен. Модель определяет уровень подтвержденного опыта, итоговую математику выполняет Python. Иначе одинаковые оценки критериев однажды могут превратиться в 78 баллов, а в другой раз — в 83. Модели, конечно, виднее, но меня такой гидрометцентр не устраивает.
По архитектуре получилась следующая схема:

LLM находится примерно в середине конвейера. Она не управляет процессом, не принимает кадровое решение и не формирует итоговый балл. Все опасные операции остаются в обычном коде. Роботу отдаем только самую рутину, все остальное реализуем самостоятельно.
Для эксперимента я придумал вакансию DevOps-инженера уровня middle. Так мне будет проще проверить модель самостоятельно, если она вдруг решит, что человек с четырьмя годами Django и одним домашним Docker Compose идеально подходит на эксплуатацию кластера Kubernetes.
Мок с описанием вакансии (чуть позже закинем его в /vacancies/middle-devops.yaml):
id: middle-devopstitle: Middle DevOps инженерdescription: | Ищем DevOps-инженера для поддержки и развития внутренней платформы разработки. Основные задачи: - администрирование Linux-серверов; - автоматизация конфигурации; - развитие CI/CD; - контейнеризация приложений; - мониторинг и диагностика production-инфраструктуры; - участие в разборе инцидентов.scoring_scale: 0: Подтверждений в резюме нет 1: Технология только упомянута 2: Есть базовый практический опыт 3: Есть самостоятельный production-опыт 4: Есть сложный опыт, ответственность или измеримый результатcriteria: - id: linux_productiontitle: Администрирование Linux в productionrequired: trueweight: 20expected_evidence: - сопровождение production-серверов - диагностика проблем - systemd - сеть - файловые системы - управление сервисами - id: automationtitle: Автоматизация конфигурацииrequired: trueweight: 15expected_evidence: - Ansible - SaltStack - Puppet - собственные инструменты автоматизации - id: containerstitle: Работа с контейнерамиrequired: trueweight: 15expected_evidence: - Docker - Docker Compose - сборка образов - диагностика контейнеров - id: ci_cdtitle: Построение и поддержка CI/CDrequired: trueweight: 15expected_evidence: - GitLab CI - Jenkins - GitHub Actions - Argo CD - автоматизация деплоя - id: monitoringtitle: Мониторинг и логированиеrequired: trueweight: 10expected_evidence: - Prometheus - Grafana - Zabbix - Loki - Alertmanager - id: troubleshootingtitle: Диагностика сложных проблемrequired: trueweight: 10expected_evidence: - разбор аварий - поиск первопричины - работа с логами и метриками - сокращение времени восстановления - id: kubernetestitle: Kubernetesrequired: falseweight: 10expected_evidence: - эксплуатация кластера - Helm - обновление - диагностика - id: terraform_cloudtitle: Terraform и облачная инфраструктураrequired: falseweight: 5expected_evidence: - Terraform - OpenStack - Selectel - Yandex Cloud - AWS - другие публичные облака
Сумма всех весов == 100. Каждый критерий модель оценивает от 0 до 4. Python переводит уровень в баллы: баллы критерия = вес × уровень / 4.
Например:
Linux productionВес: 20Уровень: 3 из 420 × 3 / 4 = 15 баллов
Почему нельзя просто искать ключевые слова? Изначально я хотел решить это регулярными выражениями:
if "kubernetes" in resume.lower():score += 10
Но тогда возникнет неувязочка с кандидатом, у которого есть такой блок:
Навыки:Linux, Docker, Kubernetes, Terraform, Ansible, GitLab CI, Jenkins, Prometheus, Grafana, AWS, Azure, GCP, OpenStack, Helm, Argo CD, Python, Bash, Go.
Он всех обманет и получит максимальный балл, хотя из текста вообще не ясно:
-
использовал ли он эти инструменты в работе,
-
настраивал ли что-нибудь самостоятельно,
-
видел ли продакшен,
-
отвечал ли за результат,
-
или просто скопировал список из вакансии.
С другой стороны, сильный SRE может не использовать Ansible, но несколько лет управлять конфигурацией через SaltStack. Поиск по ключевым словам скажет: «Ansible не найден — ноль баллов». А нормальный анализ должен выявить, что SaltStack решает тот же класс задач и подтверждает опыт автоматизации конфигурации.
Вот здесь модель будет прям полезна и в тему. Для статьи я не стал брать настоящие резюме. Вместо этого подготовил пять синтетических PDF с заранее известными особенностями. Так я сразу проверю работу пайплайна без слива персональных данных и буду понимать, что моя система должна найти. Ну или как говорят в простонародье — использую моки.
Резюме №1: сильный DevOps
DevOps-инженер
Опыт работы: 4 года
ООО «Рога и копыта»
DevOps-инженер
Май 2023 — настоящее время
-
Администрировал 60 виртуальных машин Ubuntu и Debian
-
Автоматизировал настройку серверов через Ansible
-
Разработал GitLab CI pipeline для сборки, тестирования и развертывания 18 сервисов
-
Перевел приложения в Docker и Docker Compose
-
Сократил среднее время деплоя с 35 до 12 минут
-
Настроил Prometheus, Grafana, Loki и Alertmanager
-
Участвовал в устранении production-инцидентов и подготовке postmortem
-
Поддерживал Kubernetes-кластер из восьми worker узлов
-
Создавал виртуальные машины в OpenStack через Terraform
Навыки:
Linux, Bash, Python, Ansible, Terraform, Docker, Kubernetes, GitLab CI, Prometheus, Grafana, Loki.
Ручная оценка — соответствует профилю вакансии.
Резюме №2: сильный системный администратор
Системный администратор
Опыт работы: 6 лет
Производственная компания
Системный администратор
2019 — настоящее время
-
Поддерживал серверы CentOS и Ubuntu
-
Настраивал nginx, systemd, DNS и резервное копирование
-
Использовал Bash скрипты для типовых операций
-
Настроил Zabbix для серверов и сетевого оборудования
-
Разбирал проблемы с дисками, сетью и производительностью
-
Запускал отдельные приложения в Docker
-
Использовал GitLab CI для запуска двух внутренних скриптов
-
Писал небольшие Ansible playbook
Kubernetes и Terraform в работе не использовал
Ручная оценка — есть хорошая Linux-база, но требуется ручная проверка.
Резюме №3: backend-разработчик
Python-разработчик
Опыт работы: 4 года
-
Разрабатывал backend на Python, Django и FastAPI
-
Использовал PostgreSQL и Redis
-
Собирал Docker-образы приложений
-
Настроил несколько workflow в GitHub Actions
-
Разворачивал проекты на тестовом сервере Ubuntu
-
Работал с Sentry и логами приложений
Навыки:
Python, Django, FastAPI, Docker, PostgreSQL, Redis, GitHub Actions.
Ручная оценка — не соответствует текущему профилю вакансии. Не потому, что кандидат плохой. Просто в резюме не подтверждена большая часть требований именно этой позиции.
Резюме №4: хитрый коллекционер ключевых слов
DevOps / SRE / Cloud Engineer
Навыки:
Linux, Docker, Kubernetes, Terraform, Ansible, Jenkins, GitLab CI, Prometheus, Grafana, Loki, ELK, AWS, Azure, GCP, OpenStack, Helm, Argo CD, Python, Bash, Go.
Опыт работы:
Системный администратор
2023–2025
Работа с серверами, облаками, контейнерами, автоматизацией и мониторингом.
Ручная оценка — требуется ручная проверка. Технологий много, доказательств почти нет. Это отдельный тест на то, сможет ли модель отличить реальный опыт от кейворд-стафинга (попытки обойти ATS).
Резюме №5: SRE с альтернативным стеком
Site Reliability Engineer
Опыт работы: 3 года
-
Поддерживал Linux инфраструктуру крупного сервиса
-
Управлял конфигурацией серверов через SaltStack
-
Сопровождал Kubernetes-кластеры и Helm-чарты
-
Настроил Argo CD для GitOps деплоя
-
Создавал инфраструктуру в AWS через Terraform
-
Построил мониторинг на Prometheus и Grafana
-
Снизил MTTR production-инцидентов с 50 до 20 минут
-
Проводил разбор аварий
-
Автоматизировал типовые диагностические проверки
Навыки:
Linux, SaltStack, Kubernetes, Helm, Argo CD, Terraform, AWS, Prometheus, Grafana, Python.
Ручная оценка — соответствует профилю вакансии. Ansible и GitLab CI не указаны, но есть близкий и сильный опыт с SaltStack и Argo CD.
Если модель занимается только поиском ключевых слов, она занижает оценку.
Так, болванки готовы, теперь рисуем структуру проекта, у меня получилось так:
resume-matcher/├── .env├── requirements.txt├── vacancies/│ └── middle-devops.yaml├── resumes/│ ├── resume-001.pdf│ ├── resume-002.pdf│ ├── resume-003.pdf│ ├── resume-004.pdf│ └── resume-005.pdf├── results/├── templates/│ └── report.html.j2└── src/├── __init__.py├── models.py├── pdf_parser.py├── anonymizer.py├── fmc_client.py├── scoring.py├── report.py└── main.py
Создаем сервис в облаке
Теперь надо создать инференс-сервис. Идем в панель управления → Продукты → Foundation Models Catalog и выбираем:

Для теста я выбрал:
-
Модель: t-pro-it-2-1,
-
Инстансы: 1,
-
Масштабирование: фиксированное,
-
GPU: 4 x NVIDIA L4 (24 ГБ),
-
Контекст: 16 384.


Конфигурацию после создания изменить нельзя, а количество инстансов можно масштабировать потом отдельно. Создание сервиса может занимать около 15 минут, так что наберитесь терпения и попейте кофейку.

Кстати, дополнительно можно взгромоздить WebUI. Но в моей задаче оно не требуется, поэтому этот шаг пропускаю.

После запуска сервис выдал креды:


Для каждого инференс-сервиса используются отдельные API-ключи. При создании первый ключ выпускается автоматически.
Создаем .env-файл и заполняем его полученными кредами:
FMC_ENDPOINT=https://6a42e1c7-9ac8-423e-8eb1-be4dd09c66e5.wc.ru-7.inference.selcloud.ruFMC_API_KEY=1234567890FMC_MODEL=t-pro-it-2-1
И сразу добавляем его в .gitignore, API-ключу делать в Git нечего.
.env.venv/__pycache__/results/
Перед написанием приложения отправляем банальный запрос через curl, чтобы сразу все проверить и не городить огород с багами.
curl https://6a42e1c7-9ac8-423e-8eb1-be4dd09c66e5.wc.ru-7.inference.selcloud.ru/v1/completions \-H "Authorization: Bearer 1234567890" \-H "Content-Type: application/json" \-d '{ "model": "t-pro-it-2-1", "prompt": "Say this is a test", "temperature": 0, "max_tokens": 7}'

Тестовый запрос прошел, давайте сделаем еще один и смажем JQ:
cd ~/dev/resume-matchersource .envcurl -sS \ "${FMC_ENDPOINT}/v1/chat/completions" \ -H "Authorization: Bearer ${FMC_API_KEY}" \ -H "Content-Type: application/json" \ -d "{\"model\": \"${FMC_MODEL}\",\"temperature\": 0,\"max_tokens\": 700,\"messages\": [ { \"role\": \"system\", \"content\": \"Ты анализируешь профессиональный опыт из резюме.\" }, { \"role\": \"user\", \"content\": \"Верни JSON с одним полем status и значением ok.\" }] }" |jq -r '.choices[0].message.content'
Ожидаемый результат получен, можно ехать дальше.

FMC поддерживает Chat API по адресу
/v1/chat/completions, Bearer-авторизацию и ответы в формате OpenAI API. Эндпоинт, API-ключ и имя модели можно скопировать из панели управления.
Если получаете 401 Unauthorized, то проверяйте креды. Вот для этого тесты и нужны, чтобы потом вся задумка не превратилась в тыкву.
Теперь устанавливаем зависимости:
python3 -m venv .venvsource .venv/bin/activate
Далее устанавливаем пакеты:
pip install pymupdf requests pydantic python-dotenv pyyaml jinja2

Набор пакетов я выбрал, потому, что:
-
pymupdf извлекает текст из PDF,
-
requests обращается к FMC,
-
pydantic проверяет ответ модели,
-
pyyaml читает описание вакансии,
-
jinja2 формирует HTML-отчет,
-
python-dotenv загружает настройки.
Дальше будет много кода. На роль Python-разработчика я не претендую, поэтому для профильного специалиста код может выглядеть как лапша. В любом случае код рабочий и меня он вполне устраивает.
Создаем src/pdf_parser.py:
from __future__ import annotationsimport refrom pathlib import Pathfrom typing import castimport pymupdf as fitzclass PdfTextError(RuntimeError):passdef normalize_text(text: str) -> str:text = text.replace("\u00a0", " ")text = text.replace("\u200b", "")text = text.replace("\xad", "")lines = [ re.sub(r"[ \t]+", " ", line).strip() for line in text.splitlines()]text = "\n".join(lines)text = re.sub(r"\n{3,}", "\n\n", text)return text.strip()def extract_pdf_text(pdf_path: Path) -> str:if not pdf_path.is_file(): raise FileNotFoundError( f"PDF не найден: {pdf_path}" )pages: list[str] = []try: with fitz.open(pdf_path) as document: if document.page_count == 0: raise PdfTextError( "В PDF нет страниц" ) for page_index in range( document.page_count ): page_number = page_index + 1 page = document.load_page( page_index ) page_text = cast( str, page.get_text( "text", sort=True, ), ) page_text = normalize_text( page_text ) if not page_text: continue pages.append( f"--- Страница {page_number} ---\n" f"{page_text}" )except fitz.FileDataError as error: raise PdfTextError( f"Не удалось прочитать PDF: {error}" ) from errorresult = "\n\n".join(pages).strip()if len(result) < 300: raise PdfTextError( "Из PDF извлечено слишком мало текста. " "Возможно, документ является сканом " "без текстового слоя." )return result
Проверяем:
from pathlib import Pathfrom src.pdf_parser import extract_pdf_texttext = extract_pdf_text(Path("resumes/resume-001.pdf"))print(text[:1500])
В результате получаем:
--- Страница 1 --- DevOps-инженер Опыт работы: 4 года ООО «Рога и копыта» DevOps-инженер Май 2022 — настоящее время Администрировал 60 виртуальных машин Ubuntu и Debian.
PyMuPDF не выполняет OCR. Если PDF состоит из отсканированных изображений, текстового слоя там может не быть. В этом случае я не отправляю пустой документ в модель, а помещаю резюме в очередь ручной проверки:
extraction_quality: poorverdict: manual_review
Теоретически можно подключить Tesseract или отдельную OCR-модель, но я предпочитаю, чтобы под один проект была одна модель. Поэтому в эксперименте я использую PDF с нормальным текстовым слоем.

ИИ‑роутер — доступ к 300+ моделям из одной панели
Подключите OpenAI‑совместимый шлюз по единому API‑ключу. Настраивайте сквозную аналитику, отслеживайте лимиты и квотируйте ресурсы.
Обезличиваем документ
В настоящем резюме содержатся персональные данные:
-
имя;
-
телефон;
-
почта;
-
адрес;
-
дата рождения;
-
ссылки на соцсети.
Для проверки профессионального соответствия они не нужны, да и трудовое законодательство запрещает ограничения и преимущества по обстоятельствам, не связанным с деловыми качествами кандидата. Это часть цитаты из Консультанта.
Создаем src/anonymizer.py:
from __future__ import annotations import reEMAIL_PATTERN = re.compile(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b",flags=re.IGNORECASE,)PHONE_PATTERN = re.compile(r"(?<!\d)(?:\+7|8)"r"[\s\-()]*(?:\d[\s\-()]*){10}(?!\d)")URL_PATTERN = re.compile(r"(?:https?://|www\.|t\.me/)\S+",flags=re.IGNORECASE,)SENSITIVE_LINE_PATTERN = re.compile(r"^(?:"r"дата рождения|"r"возраст|"r"пол|"r"семейное положение|"r"гражданство|"r"адрес|"r"место проживания"r")\s*:.*$",flags=re.IGNORECASE | re.MULTILINE,)def anonymize_resume(text: str) -> str:result = EMAIL_PATTERN.sub( "[EMAIL REMOVED]", text,)result = PHONE_PATTERN.sub( "[PHONE REMOVED]", result,)result = URL_PATTERN.sub( "[LINK REMOVED]", result,)result = SENSITIVE_LINE_PATTERN.sub( "[PERSONAL DATA REMOVED]", result,)return result
Оговорюсь, что здесь у меня демонстрационный фильтр, а не продакшен-система обезличивания. Фильтр удалит очевидный телефон и email, но может не распознать имя или необычно записанный адрес. В продакшене такой этап придется усиливать и отдельно проверять с юристами и специалистами по ИБ. Но для синтетических документов этого достаточно.
Описываем ожидаемый ответ
Создаем src/models.py:
from __future__ import annotationsfrom typing import Literalfrom pydantic import (BaseModel,Field,field_validator,)CriterionStatus = Literal["confirmed","partial","not_found","contradicted",]class CriterionAssessment(BaseModel):id: strlevel: int = Field( ge=0, le=4,)status: CriterionStatusevidence: list[str] = Field( default_factory=list, max_length=5,)comment: str = Field( min_length=1, max_length=800,)@field_validator("evidence")@classmethoddef clean_evidence( cls, values: list[str],) -> list[str]: result = [] for value in values: value = value.strip() if value and value not in result: result.append(value) return resultclass ModelAssessment(BaseModel):candidate_summary: str = Field( min_length=1, max_length=1000,)extraction_quality: Literal[ "good", "partial", "poor",]criteria: list[CriterionAssessment]missing_required: list[str] = Field( default_factory=list,)unclear_points: list[str] = Field( default_factory=list, max_length=10,)interview_questions: list[str] = Field( default_factory=list, max_length=10,)short_reason: str = Field( min_length=1, max_length=1200,)
Библиотека pydantic проверяет:
-
наличие всех полей,
-
типы значений,
-
диапазон уровня от 0 до 4,
-
допустимые статусы,
-
максимальное количество доказательств и вопросов.
Модель может вернуть вот такой результат:
{ "level": "отлично"}
Тогда проверка завершится ошибкой.
Создаем промпт
Теперь пришло время создать промт. Создаем файл src/fmc_client.py и сначала пишем системную инструкцию:
SYSTEM_PROMPT = """Ты анализируешь соответствие профессионального опытаиз резюме требованиям конкретной вакансии.Это не оценка личности кандидата и не окончательноерешение о найме или отказе.Оценивай только сведения, которые явно присутствуютв переданном тексте резюме.Правила:1. Не учитывай имя, пол, возраст, семейное положение, гражданство, адрес, фотографию и другие личные признаки.2. Не придумывай опыт, которого нет в резюме.3. Упоминание технологии без описания ее применения дает не более 1 балла.4. Статус not_found означает только отсутствие подтверждения в тексте. Он не означает, что кандидат точно не обладает навыком.5. Учитывай эквивалентные инструменты и подходы. Например: - SaltStack подтверждает опыт управления конфигурацией; - Argo CD подтверждает опыт автоматизации деплоя и GitOps; - Zabbix подтверждает опыт мониторинга.6. Для каждого критерия приводи короткие дословные доказательства из резюме.7. Не давай рекомендации по улучшению резюме.8. Не принимай решение о найме или автоматическом отказе.9. Не рассчитывай общий балл.10. Верни только JSON без Markdown, вступления и пояснений.Шкала уровня:0 — в тексте нет подтверждения;1 — технология только упомянута;2 — подтвержден базовый практический опыт;3 — подтвержден самостоятельный production-опыт;4 — подтвержден сложный опыт, ответственностьза результат или измеримое достижение.""".strip()
Пользовательский промпт будет содержать:
КРИТЕРИИ ВАКАНСИИ
…
ТЕКСТ РЕЗЮМЕ
…
ОЖИДАЕМАЯ JSON-СХЕМА
…
Так модель знает, какие именно поля мы будем проверять.
Запускаем FMC
Дополняем файл src/fmc_client.py:
from __future__ import annotationsimport jsonimport osimport reimport timefrom typing import Anyimport requestsfrom src.models import ModelAssessmentclass FmcError(RuntimeError):passdef extract_json_object(raw_content: str,) -> dict[str, Any]:raw_content = raw_content.strip()raw_content = re.sub( r"^```(?:json)?\s*", "", raw_content, flags=re.IGNORECASE,)raw_content = re.sub( r"\s*```$", "", raw_content,)start = raw_content.find("{")end = raw_content.rfind("}")if start == -1 or end == -1 or end < start: raise FmcError( "Модель не вернула JSON-объект" )try: parsed = json.loads( raw_content[start:end + 1] )except json.JSONDecodeError as error: raise FmcError( f"Некорректный JSON: {error}" ) from errorif not isinstance(parsed, dict): raise FmcError( "Корнем ответа должен быть JSON-объект" )return parsedclass FmcClient:def __init__(self) -> None: self.endpoint = os.environ[ "FMC_ENDPOINT" ].rstrip("/") self.api_key = os.environ[ "FMC_API_KEY" ] self.model = os.environ[ "FMC_MODEL" ] self.session = requests.Session() self.session.headers.update( { "Authorization": ( f"Bearer {self.api_key}" ), "Content-Type": ( "application/json" ), } )def analyze( self, vacancy: dict[str, Any], resume_text: str,) -> tuple[ModelAssessment, float]: response_schema = ( ModelAssessment.model_json_schema() ) user_prompt = ( "КРИТЕРИИ ВАКАНСИИ:\n" + json.dumps( vacancy, ensure_ascii=False, indent=2, ) + "\n\nТЕКСТ РЕЗЮМЕ:\n" + resume_text + "\n\nОЖИДАЕМАЯ JSON-СХЕМА:\n" + json.dumps( response_schema, ensure_ascii=False, indent=2, ) ) payload = { "model": self.model, "temperature": 0, "max_tokens": 700, "messages": [ { "role": "system", "content": SYSTEM_PROMPT, }, { "role": "user", "content": user_prompt, }, ], } started = time.perf_counter() response = self.session.post( ( f"{self.endpoint}" "/v1/chat/completions" ), json=payload, timeout=240, ) elapsed = ( time.perf_counter() - started ) if not response.ok: raise FmcError( f"FMC API error " f"{response.status_code}: " f"{response.text}" ) try: response_payload = ( response.json() ) raw_content = ( response_payload["choices"][0] ["message"]["content"] ) except ( ValueError, KeyError, IndexError, TypeError, ) as error: raise FmcError( "Не удалось разобрать ответ FMC" ) from error parsed = extract_json_object( raw_content ) assessment = ( ModelAssessment.model_validate( parsed ) ) return assessment, elapsed
Параметр temperature я устанавливаю в ноль, чтобы снизить случайность ответа.
Но даже при нулевой температуре полная повторяемость не гарантируется. Поэтому позже отдельно проверим стабильность на нескольких запусках.
Не доверяем цитатам модели
В ответе модель должна приводить доказательства из резюме. Но что мешает ей меня обмануть или вообще придумать красивую цитату? Чтобы избежать этого, создаем функцию:
import refrom dataclasses import dataclassfrom src.models import ModelAssessment@dataclassclass EvidenceCheck:total: intmatched: intmissing: list[str]def normalize_for_matching(text: str,) -> str:text = text.lower()text = text.replace("ё", "е")text = re.sub(r"\s+", " ", text)return text.strip()def check_evidence(resume_text: str,assessment: ModelAssessment,) -> EvidenceCheck:normalized_resume = ( normalize_for_matching( resume_text ))total = 0matched = 0missing: list[str] = []for criterion in assessment.criteria: for evidence in criterion.evidence: total += 1 normalized_evidence = ( normalize_for_matching( evidence ) ) if ( normalized_evidence in normalized_resume ): matched += 1 else: missing.append(evidence)return EvidenceCheck( total=total, matched=matched, missing=missing,)
Если модель вернула Самостоятельно спроектировал Kubernetes-кластер, а в резюме такой строки нет, доказательство помечается как неподтвержденное. Это не идеальная проверка: модель может убрать знак препинания или сократить цитату. Но для первого варианта я специально сделал дословные фрагменты, чтобы результат можно было проверить обычным поиском по строке.
Считаем итоговый балл
Создаем файл src/scoring.py:
from __future__ import annotationsfrom typing import Anyfrom src.models import ModelAssessmentdef calculate_result(vacancy: dict[str, Any],assessment: ModelAssessment,) -> dict[str, Any]:criteria_config = { item["id"]: item for item in vacancy["criteria"]}model_results = { item.id: item for item in assessment.criteria}weighted_score = 0.0required_gaps: list[str] = []criterion_results = []for criterion_id, config in ( criteria_config.items()): model_result = ( model_results.get( criterion_id ) ) if model_result is None: level = 0 status = "not_found" evidence: list[str] = [] comment = ( "Модель не вернула " "оценку критерия" ) else: level = model_result.level status = model_result.status evidence = model_result.evidence comment = model_result.comment weight = int(config["weight"]) points = ( weight * level / 4 ) weighted_score += points if ( bool(config["required"]) and level < 2 ): required_gaps.append( criterion_id ) criterion_results.append( { "id": criterion_id, "title": config["title"], "required": bool( config["required"] ), "weight": weight, "level": level, "status": status, "points": round( points, 2, ), "evidence": evidence, "comment": comment, } )fit_score = round( weighted_score)if ( assessment.extraction_quality != "good"): verdict = "manual_review"elif ( fit_score >= 75 and not required_gaps): verdict = "match"elif ( fit_score >= 50 and len(required_gaps) <= 1): verdict = "manual_review"else: verdict = "no_match"return { "fit_score": fit_score, "verdict": verdict, "candidate_summary": ( assessment.candidate_summary ), "criteria": criterion_results, "required_gaps": required_gaps, "unclear_points": ( assessment.unclear_points ), "interview_questions": ( assessment.interview_questions ), "short_reason": ( assessment.short_reason ), "extraction_quality": ( assessment.extraction_quality ),}
Как трактовать результаты:
-
match— в резюме достаточно подтверждений, чтобы посмотреть кандидата в приоритетном порядке. -
manual_review— есть спорные места, проблемы с извлечением текста или неполное покрытие требований. Такое резюме нужно проверить вручную. -
no_match— в документе недостаточно подтверждений по текущему профилю вакансии.
В общем, no_match не должен автоматически отправлять кандидату отказ. Это результат сопоставления текста с конкретной рубрикой, который HR может проверить и изменить.
Загружаем вакансию
from __future__ import annotationsfrom pathlib import Pathfrom typing import Anyimport yamldef load_vacancy(path: Path,) -> dict[str, Any]:with path.open( encoding="utf-8") as file: vacancy = yaml.safe_load( file )if not isinstance(vacancy, dict): raise ValueError( "Вакансия должна быть " "YAML-объектом" )criteria = vacancy.get( "criteria")if not isinstance(criteria, list): raise ValueError( "В вакансии отсутствует " "список criteria" )weight_sum = sum( int(item["weight"]) for item in criteria)if weight_sum != 100: raise ValueError( "Сумма весов критериев " f"должна быть 100, сейчас: " f"{weight_sum}" )return vacancy
Проверка суммы весов спасет от ситуации, когда после добавления нового критерия максимальный результат становится 115 из 100. Нам такое, естественно, не подходит, поэтому тут лучше переборщить со внимательностью к деталям.
Собираем HTML-отчет
Создаем templates/report.html:
<!doctype html><html lang="ru"><head> <meta charset="utf-8"> <metaname="viewport"content="width=device-width, initial-scale=1" > <title>{{ candidate_id }} — проверка вакансии </title> <style>:root { color-scheme: dark; --background: #080a0f; --panel: #0d1016; --panel-light: #151920; --border: #303640; --text: #e5e7eb; --muted: #a4a9b3; --accent: #c9ff35; --warning: #ffca58; --danger: #ff7070;}* { box-sizing: border-box;}body { margin: 0; padding: 40px 24px; color: var(--text); background: var(--background); font-family: Inter, system-ui, -apple-system, sans-serif; line-height: 1.55;}main { max-width: 1280px; margin: 0 auto;}h1,h2,h3 { margin-top: 0;}h1 { margin-bottom: 8px; font-size: 32px;}.muted { color: var(--muted); }.summary-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin: 32px 0;}.card { padding: 24px; border: 1px solid var(--border); background: var(--panel);}.card__label { color: var(--muted); font-size: 13px; text-transform: uppercase; letter-spacing: 0.08em;}.card__value { margin-top: 10px; color: var(--accent); font-family: monospace; font-size: 30px; font-weight: 700;}table { width: 100%; border-collapse: collapse; margin: 32px 0; background: var(--panel);}th,td { padding: 18px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top;}th { color: var(--muted); background: var(--panel-light); font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em;}.points { color: var(--accent); font-family: monospace; font-weight: 700; white-space: nowrap;}.status { display: inline-block; padding: 4px 9px; border: 1px solid var(--border); font-family: monospace; font-size: 12px;}.status--confirmed { color: var(--accent);}.status--partial { color: var(--warning);}.status--not_found,.status--contradicted { color: var(--danger);}.evidence { margin: 10px 0 0; padding-left: 20px; color: var(--muted);}.two-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; margin-top: 32px;}.list { margin: 0; padding-left: 24px;}.list li + li { margin-top: 12px;}.verdict { color: var(--accent); font-size: 20px; font-weight: 700;}@media (max-width: 800px) { .summary-grid, .two-columns { grid-template-columns: 1fr; } body { padding: 24px 12px; } th, td { padding: 12px; }} </style></head><body><main> <h1>Соответствие вакансии</h1> <p class="muted">{{ vacancy_title }} · {{ candidate_id }} </p> <div class="summary-grid"><section class="card"> <div class="card__label"> Итоговый балл </div> <div class="card__value"> {{ result.fit_score }}/100 </div></section><section class="card"> <div class="card__label"> Результат </div> <div class="card__value"> {{ result.verdict }} </div></section><section class="card"> <div class="card__label"> Время обработки </div> <div class="card__value"> {{ "%.2f"|format(latency) }} с </div></section> </div> <section class="card"><h2>Краткая выжимка</h2><p> {{ result.candidate_summary }}</p><p class="muted"> {{ result.short_reason }}</p> </section> <table><thead> <tr> <th>Критерий</th> <th>Статус</th> <th>Баллы</th> <th>Доказательства</th> </tr></thead><tbody>{% for criterion in result.criteria %} <tr> <td> <strong> {{ criterion.title }} </strong> {% if criterion.required %} <div class="muted"> Обязательный критерий </div> {% endif %} </td> <td> <span class=" status status--{{ criterion.status }} " > {{ criterion.status }} </span> <p class="muted"> {{ criterion.comment }} </p> </td> <td class="points"> {{ criterion.points }} / {{ criterion.weight }} </td> <td> {% if criterion.evidence %} <ul class="evidence"> {% for evidence in criterion.evidence %} <li>{{ evidence }}</li> {% endfor %} </ul> {% else %} <span class="muted"> Подтверждение не найдено </span> {% endif %} </td> </tr>{% endfor %}</tbody> </table> <div class="two-columns"><section class="card"> <h2>Что надо уточнить</h2> {% if result.unclear_points %} <ul class="list"> {% for item in result.unclear_points %} <li>{{ item }}</li> {% endfor %} </ul> {% else %} <p class="muted"> Явных спорных мест не найдено. </p> {% endif %}</section><section class="card"> <h2>Вопросы на интервью</h2> {% if result.interview_questions %} <ul class="list"> {% for question in result.interview_questions %} <li>{{ question }}</li> {% endfor %} </ul> {% else %} <p class="muted"> Вопросы не сформированы. </p> {% endif %}</section> </div></main></body></html>
Рендерим отчет
Создаем src/report.py:
from __future__ import annotationsfrom pathlib import Pathfrom typing import Anyfrom jinja2 import (Environment,FileSystemLoader,select_autoescape,)def render_report(candidate_id: str,vacancy_title: str,result: dict[str, Any],latency: float,output_path: Path,) -> None:environment = Environment( loader=FileSystemLoader( "templates" ), autoescape=select_autoescape( ["html", "xml"] ),)template = environment.get_template( "report.html.j2")html = template.render( candidate_id=candidate_id, vacancy_title=vacancy_title, result=result, latency=latency,)output_path.parent.mkdir( parents=True, exist_ok=True,)output_path.write_text( html, encoding="utf-8",)
Собираем все в один запуск
Создаем src/main.py:
from __future__ import annotationsimport argparseimport csvimport jsonfrom pathlib import Pathfrom typing import Anyfrom dotenv import load_dotenvfrom src.anonymizer import (anonymize_resume,)from src.fmc_client import (FmcClient,FmcError,)from src.pdf_parser import (PdfTextError,extract_pdf_text,)from src.report import render_reportfrom src.scoring import (calculate_result,)def load_vacancy(path: Path,) -> dict[str, Any]:import yamlwith path.open( encoding="utf-8") as file: vacancy = yaml.safe_load( file )if not isinstance(vacancy, dict): raise ValueError( "Вакансия должна быть " "YAML-объектом" )criteria = vacancy.get( "criteria") if not isinstance(criteria, list): raise ValueError( "В вакансии нет criteria" )weight_sum = sum( int(item["weight"]) for item in criteria)if weight_sum != 100: raise ValueError( "Сумма весов должна быть 100, " f"сейчас: {weight_sum}" )return vacancydef save_json(path: Path,payload: dict[str, Any],) -> None:path.parent.mkdir( parents=True, exist_ok=True,)path.write_text( json.dumps( payload, ensure_ascii=False, indent=2, ), encoding="utf-8",)def write_summary_csv(rows: list[dict[str, Any]],output_path: Path,) -> None:output_path.parent.mkdir( parents=True, exist_ok=True,)with output_path.open( "w", encoding="utf-8", newline="",) as file: writer = csv.DictWriter( file, fieldnames=[ "candidate_id", "fit_score", "verdict", "latency_seconds", "required_gaps", "error", ], ) writer.writeheader() writer.writerows(rows)def process_resume(pdf_path: Path,vacancy: dict[str, Any],client: FmcClient,results_dir: Path,) -> dict[str, Any]:candidate_id = pdf_path.stemtry: raw_text = extract_pdf_text( pdf_path ) clean_text = anonymize_resume( raw_text ) assessment, latency = ( client.analyze( vacancy=vacancy, resume_text=clean_text, ) ) result = calculate_result( vacancy=vacancy, assessment=assessment, ) result["candidate_id"] = ( candidate_id ) result["source_file"] = ( pdf_path.name ) result["latency_seconds"] = ( round(latency, 3) ) save_json( ( results_dir / f"{candidate_id}.json" ), result, ) render_report( candidate_id=candidate_id, vacancy_title=vacancy["title"], result=result, latency=latency, output_path=( results_dir / f"{candidate_id}.html" ), ) return { "candidate_id": candidate_id, "fit_score": result["fit_score"], "verdict": result["verdict"], "latency_seconds": round( latency, 3, ), "required_gaps": ",".join( result["required_gaps"] ), "error": "", }except ( PdfTextError, FmcError, ValueError,) as error: return { "candidate_id": candidate_id, "fit_score": "", "verdict": "manual_review", "latency_seconds": "", "required_gaps": "", "error": str(error), }def main() -> None:load_dotenv()parser = argparse.ArgumentParser()parser.add_argument( "--vacancy", type=Path, required=True,)parser.add_argument( "--resumes", type=Path, required=True,)parser.add_argument( "--results", type=Path, default=Path("results"),)args = parser.parse_args()vacancy = load_vacancy( args.vacancy)pdf_files = sorted( args.resumes.glob("*.pdf"))if not pdf_files: raise RuntimeError( "В каталоге нет PDF" )client = FmcClient()summary_rows = []for number, pdf_path in enumerate( pdf_files, start=1,): print( f"[{number}/{len(pdf_files)}] " f"{pdf_path.name}" ) row = process_resume( pdf_path=pdf_path, vacancy=vacancy, client=client, results_dir=args.results, ) summary_rows.append(row) print( f" verdict={row['verdict']} " f"score={row['fit_score']}" )write_summary_csv( summary_rows, args.results / "summary.csv",)if __name__ == "__main__":main()
Итого получаем такую структуру:

Запускаем сервис
source .venv/bin/activatepython3 -m src.main --vacancy vacancies/middle-devops.yaml --resumes resumes --results results
Спустя пару минут, в папке results видим новые файлы и сам результат в консоли:


Можно сказать, это успех. Оно работает! Теперь можно посмотреть красивые отчеты по каждому резюме:





Не стал бы утверждать, что это прям готовая ATS, которую можно внедрять к себе в CRM и пачками отсеивать вайбкодеров.
Вот что получается по кандидату №5, который набрал 78 баллов:
{ "fit_score": 78, "verdict": "match", "candidate_summary": "Site Reliability Engineer с 3 годами опыта в администрировании Linux, автоматизации, Kubernetes, CI/CD и мониторинге в production.", "criteria": [{ "id": "linux_production", "title": "Администрирование Linux в production", "required": true, "weight": 20, "level": 3, "status": "confirmed", "points": 15.0, "evidence": [ "Поддерживал Linux-инфраструктуру крупного сервиса." ], "comment": "production опыт"},{ "id": "automation", "title": "Автоматизация конфигурации", "required": true, "weight": 15, "level": 3, "status": "confirmed", "points": 11.25, "evidence": [ "Управлял конфигурацией серверов через SaltStack." ], "comment": "SaltStack в production"},{ "id": "containers", "title": "Работа с контейнерами", "required": true, "weight": 15, "level": 3, "status": "confirmed", "points": 11.25, "evidence": [ "Сопровождал Kubernetes-кластеры и Helm-чарты." ], "comment": "K8s и Helm"},{ "id": "ci_cd", "title": "Построение и поддержка CI/CD", "required": true, "weight": 15, "level": 3, "status": "confirmed", "points": 11.25, "evidence": [ "Настроил Argo CD для GitOps-деплоя." ], "comment": "GitOps через Argo CD"},{ "id": "monitoring", "title": "Мониторинг и логирование", "required": true, "weight": 10, "level": 3, "status": "confirmed", "points": 7.5, "evidence": [ "Построил мониторинг на Prometheus и Grafana." ], "comment": "Prometheus + Grafana"},{ "id": "troubleshooting", "title": "Диагностика сложных проблем", "required": true, "weight": 10, "level": 4, "status": "confirmed", "points": 10.0, "evidence": [ "Снизил MTTR production-инцидентов с 50 до 20 минут.", "Проводил разбор аварий." ], "comment": "результат по MTTR"},{ "id": "kubernetes", "title": "Kubernetes", "required": false, "weight": 10, "level": 3, "status": "confirmed", "points": 7.5, "evidence": [ "Сопровождал Kubernetes-кластеры и Helm-чарты." ], "comment": "поддержка K8s"},{ "id": "terraform_cloud", "title": "Terraform и облачная инфраструктура", "required": false, "weight": 5, "level": 3, "status": "confirmed", "points": 3.75, "evidence": [ "Создавал инфраструктуру в AWS через Terraform." ], "comment": "Terraform + AWS"} ], "required_gaps": [], "unclear_points": [], "interview_questions": [ "Какой тип диагностики автоматизировали и как измеряли эффективность?", "Какие конкретные улучшения в Argo CD были внедрены?" ], "short_reason": "Полный стек SRE с доказанными результатами", "extraction_quality": "good", "candidate_id": "resume-005", "source_file": "resume-005.pdf", "latency_seconds": 39.67}
Рисуем табличку, чтобы нагляднее было:

Итого: 2 совпадения из 5 (40%). два кандидата получили match, два отправлены на ручную проверку и один получил no_match. При этом модель не спрятала потенциально подходящих кандидатов в no_match, выбрав для спорных случаев более осторожный manual_review. Идем уверенно!
Самые интересные здесь два последних резюме. Кандидат №4 проверяет, даст ли модель высокий балл за красивый список технологий без доказательств. Кандидат №5 показывает, способна ли модель учитывать альтернативный стек, а не искать только точные совпадения с вакансией.
Проверяем стабильность
Одно и то же резюме прогоняю пять раз:
for run in {1..5}; do mkdir -p "results/run-$run" python3 -m src.main \--vacancy vacancies/middle-devops.yaml \--resumes resumes-one \--results "results/run-$run"done
Сравниваю итоговый балл, verdict, уровни критериев, доказательства и вопросы для интервью.

Результат полностью стабилен:
-
разброс 0 баллов,
-
совпадение вердиктов 5 из 5,
-
уровни всех критериев совпали,
-
доказательства совпали дословно,
-
вопросы для интервью совпали дословно.
Тут важно, что если балл гуляет на два-три пункта, с этим еще можно жить. Если один запуск говорит match, а другой no_match, такой инструмент использовать нельзя, пока не будет стабильности. Тоже учитывай этот момент.
Что еще можно измерить
Валидность JSON
valid_json_rate = успешно проверенные ответы / все ответы. То есть, например,
при 49 валидных ответах из 50 valid_json_rate = 98%.
Достоверность доказательств
evidence_match_rate = дословно найденные цитаты / все цитаты. Если модель придумала цитату, результат должен быть помечен для ручной проверки.
Время обработки
Для каждого PDF сохраняется latency_seconds:

Оценка времени для 400 резюме
При последовательной обработке общее время будет равно среднему времени на проверку одного резюме × 400. То есть если одно резюме обрабатывается 12 секунд, то 12 × 400 = 4 800 секунд ≈ 80 минут. Но это пока только арифметическая оценка.
Реальное время зависит от размера резюме, конфигурации инференс-сервиса, нагрузки, количества одновременных запросов, лимитов сервиса и повторных запросов после ошибок.
После последовательного теста можно аккуратно добавить два или четыре worker-потока и сравнить throughput. Сразу запускать 50 параллельных запросов только потому, что это все надо срочно и вчера — плохая идея.
Ложные отрицательные результаты
Главный вопрос для такой системы — не спрятали ли мы сильного кандидата в хвост выдачи? Если HR вручную отметил 20 подходящих кандидатов, а система поместила 18 из них в приоритетную очередь, то recall = 18 / 20 = 0.9.
В этом сценарии лучше показать человеку несколько лишних резюме, чем потерять одного подходящего специалиста. Поэтому пороги я бы настраивал в сторону manual_review, а не агрессивного no_match. Если опасаетесь, что это будет слишком затратно для бюджета на инфраструктуру, то нет. FMC использует модель оплаты по потреблению облачных ресурсов, а не токенов. То есть средства списываются за ресурсы инференс-сервера: GPU, vCPU, RAM и диск, а количество токенов отдельно не тарифицируется.
Здесь еще важный момент в том, что модель оценивает не только ключевые слова. Это важно, чтобы сильный SRE-инженер с SaltStack и Argo CD не получил ноль баллов за отсутствие Ansible и GitLab CI. К тому же, по каждому критерию есть объяснение. Так что благодаря настройке в сторону manual_review повышается шанс, что HR увидит не абстрактные «88 баллов», а конкретные фрагменты из резюме. Например, что кандидат снизил MTTR с 50 до 20 минут.
Итоговый балл считается обычным кодом, LLM не занимается математикой и не определяет пороги. Уровни критериев всегда дают одинаковый результат. PDF не отправляется в модель целиком, сначала локально извлекается текст, затем удаляются очевидные персональные данные. Модель получает только очищенную текстовую версию.
Tools не нужны, их отсутствие вообще не мешает. Все действия заранее определены:

Где начались неожиданности
PDF — это не всегда текст. Красивое резюме может извлекаться в странном порядке. Таблица навыков иногда перемешивается с опытом, а скан вообще не содержит текстового слоя. Поэтому я сохраняю извлеченный текст, чтобы самому посмотреть, что конкретно уйдет в модель.
Ключевые слова продолжают влиять на результат. Даже с хорошим промптом модель может завышать кандидата, у которого перечислено 20 технологий. Поэтому в инструкции явно написано: Упоминание без применения — не больше 1 балла. Именно для этого нужен отдельный тест с keyword stuffing, на случай, когда кандидат пытается обмануть подобную систему распознавания.
Еще модель иногда переформулирует доказательства, поэтому я требую дословные цитаты. Но тут выясняется, что LLM может слегка переписать исходную фразу. Тогда простая проверка подстроки считает цитату неподтвержденной. Можно перейти к нечеткому сравнению, но я бы сначала оставил строгий режим. Лучше получить лишнее предупреждение, чем принять придуманное доказательство за настоящее.
Порог 75 баллов — не истина, настоящие пороги надо подбирать на размеченном наборе резюме и отдельно для каждой вакансии. Резюме не описывает человека полностью, так что если технология не указана, это еще не означает, что кандидат с ней не работал. Поэтому статусы я назвал not_found, а не candidate_does_not_know. Но это уже детали и шлифовка напильником.
Чего нет в этом сервисе
Описанный мной сервис:
-
не отправляет отказы,
-
не пишет кандидатам,
-
не назначает встречи,
-
не меняет статусы в ATS,
-
не удаляет резюме,
-
не принимает решение о найме,
-
не оценивает личность человека,
-
не анализирует возраст, пол или фотографию.
В реальном использовании HR по итогу просто получает список кандидатов и открывает оригинальные документы. Моя автоматизация помогает добраться до сильного кандидатов быстрее, но не убирает человека из процесса.
Итоги сего мероприятия
Начиналось все с простой картины: одному HR нужно было за день разобрать 400 резюме в PDF, не отсеять сильных кандидатов и не пропустить слабых.
Вся механика выполняется на обычном Python. Модель отвечает только за смысловую часть: ищет в резюме подтверждения профессионального опыта и сопоставляет их с требованиями вакансии.
Я выбрал text-to-text-модель, создал сервис и получил готовый эндпоинт, не разворачивая GPU-инфраструктуру самостоятельно.
Самое важное — я не старался превратить LLM в цифрового HR-директора. Она не решает судьбу кандидата, но помогает человеку разобрать входящий поток и показывает, на какие резюме стоит посмотреть в первую очередь. По-моему, это как раз нормальная автоматизация — не заменить специалиста, а забрать у него рутину, от которой он уже плачет кровавыми слезами.
Конец!
ссылка на оригинал статьи https://habr.com/ru/articles/1085956/