Настройка проверки PDF-файлов с помощью ИИ

Как автоматизировать оценку 400 резюме с помощью 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.pdf88/100соответствует профилю
resume-002.pdf61/100требуется ручная проверка
resume-003.pdf34/100недостаточно подтверждений
resume-004.pdf42/100много ключевых слов, мало доказательств
resume-005.pdf84/100соответствует профилю

При этом по каждому баллу должны быть видны доказательства из исходного текста:

Критерий: CI/CD
Уровень: 4 из 4
Доказательства:

  • Разработал GitLab CI pipeline для сборки, тестирования и развертывания 18 сервисов.
  • Сократил время деплоя с 35 до 12 минут.

Если доказательства нет, модель должна честно написать: «В тексте резюме подтверждение не найдено», а не додумывать опыт кандидата.

Для анализа резюме подойдет обычная text-to-text-модель. На вход отправляем вакансию, критерии и извлеченный текст, на выходе ожидаем структурированный JSON. Никакие model tools для этого не потребуются. Последовательностью действий управляет Python:

Последовательность действий: PDF - текст - обезличивание - API - JSON - балл - отчет.

Модель не должна сама открывать файл, выполнять команды или менять статус кандидата в кадровой системе. Она решает только одну задачу — делает структурированную оценку по заданным критериям на основе текста из резюме.

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

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.

Ручная оценка — соответствует профилю вакансии.

Системный администратор

Опыт работы: 6 летПроизводственная компания

Системный администратор

2019 — настоящее время

  • Поддерживал серверы CentOS и Ubuntu
  • Настраивал nginx, systemd, DNS и резервное копирование
  • Использовал Bash скрипты для типовых операций
  • Настроил Zabbix для серверов и сетевого оборудования
  • Разбирал проблемы с дисками, сетью и производительностью
  • Запускал отдельные приложения в Docker
  • Использовал GitLab CI для запуска двух внутренних скриптов
  • Писал небольшие Ansible playbook

Kubernetes и Terraform в работе не использовал

Ручная оценка — есть хорошая Linux-база, но требуется ручная проверка.

Python-разработчик

Опыт работы: 4 года

  • Разрабатывал backend на Python, Django и FastAPI
  • Использовал PostgreSQL и Redis
  • Собирал Docker-образы приложений
  • Настроил несколько workflow в GitHub Actions
  • Разворачивал проекты на тестовом сервере Ubuntu
  • Работал с Sentry и логами приложений

Навыки:
Python, Django, FastAPI, Docker, PostgreSQL, Redis, GitHub Actions.

Ручная оценка — не соответствует текущему профилю вакансии. Не потому, что кандидат плохой. Просто в резюме не подтверждена большая часть требований именно этой позиции.

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

Работа с серверами, облаками, контейнерами, автоматизацией и мониторингом.

Ручная оценка — требуется ручная проверка. Технологий много, доказательств почти нет. Это отдельный тест на то, сможет ли модель отличить реальный опыт от кейворд-стафинга (попытки обойти АТС).

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 и выбираем:

Скриншот из панели управления Selectel, страница выбора продуктов.

Для теста я выбрал:

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

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

Скриншот из панели управления Selectel, настройка FMC, этап подтверждения конфигурации.

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

Раздел для подключения Open WebUI при настройке FMC.

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

Скриншот из панели управления Selectel, FMC, вкладка "Быстрый старт".
Скриншот из панели управления Selectel, FMC, вкладка "API-ключи".

Для каждого инференс-сервиса используются отдельные 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
}'
Пример выполнения curl-запроса к API модели ИИ.

Тестовый запрос прошел, давайте сделаем еще один и смажем 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'

Ожидаемый результат получен, можно ехать дальше.

Запрос к API чата через curl с обработкой jq.

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
Процесс установки зависимостей Python-проекта через pip в терминале.

Набор пакетов я выбрал, потому, что:

  • 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 с нормальным текстовым слоем.

Обезличиваем документ

В настоящем резюме содержатся персональные данные:

  • имя;
  • телефон;
  • почта;
  • адрес;
  • дата рождения;
  • ссылки на соцсети.

Для проверки профессионального соответствия они не нужны, да и трудовое законодательство запрещает ограничения и преимущества по обстоятельствам, не связанным с деловыми качествами кандидата. Это часть цитаты из Консультанта.

Создаем 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 видим новые файлы и сам результат в консоли:

Запуск скрипта сопоставления резюме и вакансий в командной строке.
Список сгенерированных файлов HTML, JSON и CSV с результатами обработки резюме.

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

Отчет о соответствии резюме требованиям вакансии с оценкой кандидатуры.
Детальный отчет оценки соответствия второго резюме требованиям вакансии.
Отчет оценки соответствия третьего резюме требованиям вакансии.
Отчет оценки соответствия четвертого резюме требованиям вакансии.
Отчет оценки соответствия пятого резюме требованиям вакансии.

Не стал бы утверждать, что это прям готовая 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
}

Рисуем табличку, чтобы нагляднее было:

Сводная таблица сравнения ручной оценки кандидатов и результатов FMC.

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

Конец!