Когда в день вам присылают 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-директора. Она не решает судьбу кандидата, но помогает человеку разобрать входящий поток и показывает, на какие резюме стоит посмотреть в первую очередь. По-моему, это как раз нормальная автоматизация — не заменить специалиста, а забрать у него рутину, от которой он уже плачет кровавыми слезами.

Конец!

Комментарии (0)