Автоматизируем разбор резюме с помощью LLM, Python и FMC


Когда в день вам присылают 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-devops
title: Middle DevOps инженер
description: |
Ищем DevOps-инженера для поддержки и развития внутренней
платформы разработки.
Основные задачи:
- администрирование Linux-серверов;
- автоматизация конфигурации;
- развитие CI/CD;
- контейнеризация приложений;
- мониторинг и диагностика production-инфраструктуры;
- участие в разборе инцидентов.
scoring_scale:
0: Подтверждений в резюме нет
1: Технология только упомянута
2: Есть базовый практический опыт
3: Есть самостоятельный production-опыт
4: Есть сложный опыт, ответственность или измеримый результат
criteria:
- id: linux_production
title: Администрирование Linux в production
required: true
weight: 20
expected_evidence:
- сопровождение production-серверов
- диагностика проблем
- systemd
- сеть
- файловые системы
- управление сервисами
- id: automation
title: Автоматизация конфигурации
required: true
weight: 15
expected_evidence:
- Ansible
- SaltStack
- Puppet
- собственные инструменты автоматизации
- id: containers
title: Работа с контейнерами
required: true
weight: 15
expected_evidence:
- Docker
- Docker Compose
- сборка образов
- диагностика контейнеров
- id: ci_cd
title: Построение и поддержка CI/CD
required: true
weight: 15
expected_evidence:
- GitLab CI
- Jenkins
- GitHub Actions
- Argo CD
- автоматизация деплоя
- id: monitoring
title: Мониторинг и логирование
required: true
weight: 10
expected_evidence:
- Prometheus
- Grafana
- Zabbix
- Loki
- Alertmanager
- id: troubleshooting
title: Диагностика сложных проблем
required: true
weight: 10
expected_evidence:
- разбор аварий
- поиск первопричины
- работа с логами и метриками
- сокращение времени восстановления
- id: kubernetes
title: Kubernetes
required: false
weight: 10
expected_evidence:
- эксплуатация кластера
- Helm
- обновление
- диагностика
- id: terraform_cloud
title: Terraform и облачная инфраструктура
required: false
weight: 5
expected_evidence:
- Terraform
- OpenStack
- Selectel
- Yandex Cloud
- AWS
- другие публичные облакаСумма всех весов == 100. Каждый критерий модель оценивает от 0 до 4. Python переводит уровень в баллы: баллы критерия = вес × уровень / 4.
Например:
Linux production
Вес: 20
Уровень: 3 из 4
20 × 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.ru
FMC_API_KEY=1234567890
FMC_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-matcher
source .env
curl -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 .venv
source .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 annotations
import re
from pathlib import Path
from typing import cast
import pymupdf as fitz
class PdfTextError(RuntimeError):
pass
def 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 error
result = "\n\n".join(pages).strip()
if len(result) < 300:
raise PdfTextError(
"Из PDF извлечено слишком мало текста. "
"Возможно, документ является сканом "
"без текстового слоя."
)
return resultПроверяем:
from pathlib import Path
from src.pdf_parser import extract_pdf_text
text = extract_pdf_text(
Path("resumes/resume-001.pdf")
)
print(text[:1500])В результате получаем:
--- Страница 1 ---
DevOps-инженер
Опыт работы: 4 года
ООО «Рога и копыта»
DevOps-инженер
Май 2022 — настоящее время
Администрировал 60 виртуальных машин Ubuntu и Debian.PyMuPDF не выполняет OCR. Если PDF состоит из отсканированных изображений, текстового слоя там может не быть. В этом случае я не отправляю пустой документ в модель, а помещаю резюме в очередь ручной проверки:
extraction_quality: poor
verdict: manual_reviewТеоретически можно подключить Tesseract или отдельную OCR-модель, но я предпочитаю, чтобы под один проект была одна модель. Поэтому в эксперименте я использую PDF с нормальным текстовым слоем.

ИИ‑роутер — доступ к 300+ моделям из одной панели
Подключите OpenAI‑совместимый шлюз по единому API‑ключу. Настраивайте сквозную аналитику, отслеживайте лимиты и квотируйте ресурсы.
Обезличиваем документ
В настоящем резюме содержатся персональные данные:
имя;
телефон;
почта;
адрес;
дата рождения;
ссылки на соцсети.
Для проверки профессионального соответствия они не нужны, да и трудовое законодательство запрещает ограничения и преимущества по обстоятельствам, не связанным с деловыми качествами кандидата. Это часть цитаты из Консультанта.
Создаем src/anonymizer.py:
from __future__ import annotations
import re
EMAIL_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 annotations
from typing import Literal
from pydantic import (
BaseModel,
Field,
field_validator,
)
CriterionStatus = Literal[
"confirmed",
"partial",
"not_found",
"contradicted",
]
class CriterionAssessment(BaseModel):
id: str
level: int = Field(
ge=0,
le=4,
)
status: CriterionStatus
evidence: list[str] = Field(
default_factory=list,
max_length=5,
)
comment: str = Field(
min_length=1,
max_length=800,
)
@field_validator("evidence")
@classmethod
def 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 result
class 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 annotations
import json
import os
import re
import time
from typing import Any
import requests
from src.models import ModelAssessment
class FmcError(RuntimeError):
pass
def 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 error
if not isinstance(parsed, dict):
raise FmcError(
"Корнем ответа должен быть JSON-объект"
)
return parsed
class 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 re
from dataclasses import dataclass
from src.models import ModelAssessment
@dataclass
class EvidenceCheck:
total: int
matched: int
missing: 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 = 0
matched = 0
missing: 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 annotations
from typing import Any
from src.models import ModelAssessment
def 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.0
required_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 annotations
from pathlib import Path
from typing import Any
import yaml
def 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">
<meta
name="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 annotations
from pathlib import Path
from typing import Any
from 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 annotations
import argparse
import csv
import json
from pathlib import Path
from typing import Any
from dotenv import load_dotenv
from 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_report
from src.scoring import (
calculate_result,
)
def load_vacancy(
path: Path,
) -> dict[str, Any]:
import yaml
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(
"Сумма весов должна быть 100, "
f"сейчас: {weight_sum}"
)
return vacancy
def 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.stem
try:
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/activate
python3 -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-директора. Она не решает судьбу кандидата, но помогает человеку разобрать входящий поток и показывает, на какие резюме стоит посмотреть в первую очередь. По-моему, это как раз нормальная автоматизация — не заменить специалиста, а забрать у него рутину, от которой он уже плачет кровавыми слезами.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.