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

—

от автора

Когда в день вам присылают 400 резюме на одну вакансию, «посмотреть всех» физически не получится. Зато это выглядит прямо как задача под автоматизацию!

Итак, у меня есть вакансия и огромная пачка PDF-файлов с резюме. Надо понять, в каких из них действительно подтверждаются нужные компетенции, где информации не хватает и кого стоит посмотреть в первую очередь. Мы не будем автоматизировать сам найм или отправку отказов. В кейсе ниже LLM будет составлять техническую выжимку и помогать разобрать очередь. Финальное решение останется за человеком.

По идее можно было бы открыть любую ИИшницу, загрузить туда пачку резюме и собрать результаты. Но это не автоматизация, а ерунда. Поэтому мне нужен API, который можно подключить и встроить в собственный процесс проверки резюме. Для меня тут важен именно уровень абстракции. Я не хочу искать GPU, скачивать веса модели, подбирать версию CUDA, поднимать vLLM, рассчитывать, влезет ли модель в видеопамять, настраивать масштабирование, мониторить отдельный ML-зоопарк и выяснять, почему после обновления драйвера все снова упало.

Статью написал Роман Шубин, CTO и автор Telegram-канала Bash Days.

Идея и подготовка

Да, у меня есть домашний LLM-стек, собранный до всех этих кризисов с памятью, но это решение мне не подошло. Оно требует постоянно держать эту махину включенной, а я частенько нахожусь в перелетах и сильно тревожусь, если оставил дома «включенный утюг». Поэтому выбор пал на FMC. Все работает в одном из московских дата-центров Selectel, не потребляет мое электричество и доступно в любое время и в любом месте. То что нужно! Self-hosted — это хорошо, но под некоторые задачи все же лучше выбирать инструмент, который будет комфортнее.

Интеграция модели в мой проект и бизнес-логика остаются на моей стороне, сервис разделяет эти зоны. То есть Selectel дает мне работающий эндпоинт, а я уже решаю, как использовать его в своем коде.

На момент моего тестирования, FMC находится на стадии public preview. Инференс-сервисы работают синхронно, а загрузка собственных моделей в каталог пока не поддерживается.

Наша задача — взять описание конкретной вакансии, извлечь текст из резюме, проверить наличие подтвержденного опыта по заранее заданным критериям и собрать понятный отчет для HR. Важно — не улучшать резюме, не оценивать человека, не анализировать фотографию, возраст или семейное положение. А именно сопоставить профессиональный опыт из документа с требованиями конкретной вакансии.

В результате вместо папки с 400 резюме я хочу получить таблицу:

Кандидат

Соответствие

Итог

resume-001.pdf

88/100

соответствует профилю

resume-002.pdf

61/100

требуется ручная проверка

resume-003.pdf

34/100

недостаточно подтверждений

resume-004.pdf

42/100

много ключевых слов, мало доказательств

resume-005.pdf

84/100

соответствует профилю

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

Критерий: CI/CD
Уровень: 4 из 4

Доказательства:

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

  • Сократил время деплоя с 35 до 12 минут.

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

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

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

Для эксперимента я взял t-tech/T-pro-it-2.1. Модель доступна в актуальном каталоге FMC вместе с T-lite, Qwen, Gemma и другими text-to-text-моделями.

Почему именно она? Потому что входные данные у меня русскоязычные. При этом в резюме встречаются английские названия технологий, команды, аббревиатуры и описание технических проектов. От модели мне нужен не творческий текст, а строгое следование инструкции:

  • обработать заранее заданный список критериев,

  • не придумывать отсутствующий опыт,

  • привести доказательства,

  • вернуть валидный JSON,

  • не рассчитывать итоговый балл самостоятельно.

Последний пункт тут очень важен. Модель определяет уровень подтвержденного опыта, итоговую математику выполняет Python. Иначе одинаковые оценки критериев однажды могут превратиться в 78 баллов, а в другой раз — в 83. Модели, конечно, виднее, но меня такой гидрометцентр не устраивает.

По архитектуре получилась следующая схема:

LLM находится примерно в середине конвейера. Она не управляет процессом, не принимает кадровое решение и не формирует итоговый балл. Все опасные операции остаются в обычном коде. Роботу отдаем только самую рутину, все остальное реализуем самостоятельно.

Для эксперимента я придумал вакансию DevOps-инженера уровня middle. Так мне будет проще проверить модель самостоятельно, если она вдруг решит, что человек с четырьмя годами Django и одним домашним Docker Compose идеально подходит на эксплуатацию кластера Kubernetes.

Мок с описанием вакансии (чуть позже закинем его в /vacancies/middle-devops.yaml):

id: middle-devopstitle: Middle DevOps инженерdescription: |  Ищем DevOps-инженера для поддержки и развития внутренней  платформы разработки.  Основные задачи:  - администрирование Linux-серверов;  - автоматизация конфигурации;  - развитие CI/CD;  - контейнеризация приложений;  - мониторинг и диагностика production-инфраструктуры;  - участие в разборе инцидентов.scoring_scale:  0: Подтверждений в резюме нет  1: Технология только упомянута  2: Есть базовый практический опыт  3: Есть самостоятельный production-опыт  4: Есть сложный опыт, ответственность или измеримый результатcriteria:  - id: linux_productiontitle: Администрирование Linux в productionrequired: trueweight: 20expected_evidence:  - сопровождение production-серверов  - диагностика проблем  - systemd  - сеть  - файловые системы  - управление сервисами  - id: automationtitle: Автоматизация конфигурацииrequired: trueweight: 15expected_evidence:  - Ansible  - SaltStack  - Puppet  - собственные инструменты автоматизации  - id: containerstitle: Работа с контейнерамиrequired: trueweight: 15expected_evidence:  - Docker  - Docker Compose  - сборка образов      - диагностика контейнеров  - id: ci_cdtitle: Построение и поддержка CI/CDrequired: trueweight: 15expected_evidence:  - GitLab CI  - Jenkins  - GitHub Actions  - Argo CD  - автоматизация деплоя  - id: monitoringtitle: Мониторинг и логированиеrequired: trueweight: 10expected_evidence:  - Prometheus  - Grafana  - Zabbix  - Loki  - Alertmanager  - id: troubleshootingtitle: Диагностика сложных проблемrequired: trueweight: 10expected_evidence:  - разбор аварий  - поиск первопричины  - работа с логами и метриками  - сокращение времени восстановления  - id: kubernetestitle: Kubernetesrequired: falseweight: 10expected_evidence:  - эксплуатация кластера  - Helm  - обновление  - диагностика  - id: terraform_cloudtitle: Terraform и облачная инфраструктураrequired: falseweight: 5expected_evidence:  - Terraform  - OpenStack  - Selectel  - Yandex Cloud  - AWS  - другие публичные облака

Сумма всех весов == 100. Каждый критерий модель оценивает от 0 до 4. Python переводит уровень в баллы: баллы критерия = вес × уровень / 4.

Например:

Linux productionВес: 20Уровень: 3 из 420 × 3 / 4 = 15 баллов

Почему нельзя просто искать ключевые слова? Изначально я хотел решить это регулярными выражениями:

if "kubernetes" in resume.lower():score += 10

Но тогда возникнет неувязочка с кандидатом, у которого есть такой блок:

Навыки:Linux, Docker, Kubernetes, Terraform, Ansible, GitLab CI, Jenkins, Prometheus, Grafana, AWS, Azure, GCP, OpenStack, Helm, Argo CD, Python, Bash, Go.

Он всех обманет и получит максимальный балл, хотя из текста вообще не ясно:

  • использовал ли он эти инструменты в работе,

  • настраивал ли что-нибудь самостоятельно,

  • видел ли продакшен,

  • отвечал ли за результат,

  • или просто скопировал список из вакансии.

С другой стороны, сильный SRE может не использовать Ansible, но несколько лет управлять конфигурацией через SaltStack. Поиск по ключевым словам скажет: «Ansible не найден — ноль баллов». А нормальный анализ должен выявить, что SaltStack решает тот же класс задач и подтверждает опыт автоматизации конфигурации.

Вот здесь модель будет прям полезна и в тему. Для статьи я не стал брать настоящие резюме. Вместо этого подготовил пять синтетических PDF с заранее известными особенностями. Так я сразу проверю работу пайплайна без слива персональных данных и буду понимать, что моя система должна найти. Ну или как говорят в простонародье — использую моки.

Резюме №1: сильный DevOps

DevOps-инженер

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

ООО «Рога и копыта»

DevOps-инженер

Май 2023 — настоящее время

  • Администрировал 60 виртуальных машин Ubuntu и Debian

  • Автоматизировал настройку серверов через Ansible

  • Разработал GitLab CI pipeline для сборки, тестирования и развертывания 18 сервисов

  • Перевел приложения в Docker и Docker Compose

  • Сократил среднее время деплоя с 35 до 12 минут

  • Настроил Prometheus, Grafana, Loki и Alertmanager

  • Участвовал в устранении production-инцидентов и подготовке postmortem

  • Поддерживал Kubernetes-кластер из восьми worker узлов

  • Создавал виртуальные машины в OpenStack через Terraform

Навыки:
Linux, Bash, Python, Ansible, Terraform, Docker, Kubernetes, GitLab CI, Prometheus, Grafana, Loki.

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

Резюме №2: сильный системный администратор

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

Опыт работы: 6 лет

Производственная компания

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

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

  • Поддерживал серверы CentOS и Ubuntu

  • Настраивал nginx, systemd, DNS и резервное копирование

  • Использовал Bash скрипты для типовых операций

  • Настроил Zabbix для серверов и сетевого оборудования

  • Разбирал проблемы с дисками, сетью и производительностью

  • Запускал отдельные приложения в Docker

  • Использовал GitLab CI для запуска двух внутренних скриптов

  • Писал небольшие Ansible playbook

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

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

Резюме №3: backend-разработчик

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

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

  • Разрабатывал backend на Python, Django и FastAPI

  • Использовал PostgreSQL и Redis

  • Собирал Docker-образы приложений

  • Настроил несколько workflow в GitHub Actions

  • Разворачивал проекты на тестовом сервере Ubuntu

  • Работал с Sentry и логами приложений

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

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

Резюме №4: хитрый коллекционер ключевых слов

DevOps / SRE / Cloud Engineer

Навыки:
Linux, Docker, Kubernetes, Terraform, Ansible, Jenkins, GitLab CI, Prometheus, Grafana, Loki, ELK, AWS, Azure, GCP, OpenStack, Helm, Argo CD, Python, Bash, Go.

Опыт работы:

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

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

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

Резюме №5: SRE с альтернативным стеком

Site Reliability Engineer

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

  • Поддерживал Linux инфраструктуру крупного сервиса

  • Управлял конфигурацией серверов через SaltStack

  • Сопровождал Kubernetes-кластеры и Helm-чарты

  • Настроил Argo CD для GitOps деплоя

  • Создавал инфраструктуру в AWS через Terraform

  • Построил мониторинг на Prometheus и Grafana

  • Снизил MTTR production-инцидентов с 50 до 20 минут

  • Проводил разбор аварий

  • Автоматизировал типовые диагностические проверки

Навыки:

Linux, SaltStack, Kubernetes, Helm, Argo CD, Terraform, AWS, Prometheus, Grafana, Python.

Ручная оценка — соответствует профилю вакансии. Ansible и GitLab CI не указаны, но есть близкий и сильный опыт с SaltStack и Argo CD.

Если модель занимается только поиском ключевых слов, она занижает оценку.

Так, болванки готовы, теперь рисуем структуру проекта, у меня получилось так:

resume-matcher/├── .env├── requirements.txt├── vacancies/│   └── middle-devops.yaml├── resumes/│   ├── resume-001.pdf│   ├── resume-002.pdf│   ├── resume-003.pdf│   ├── resume-004.pdf│   └── resume-005.pdf├── results/├── templates/│   └── report.html.j2└── src/├── __init__.py├── models.py├── pdf_parser.py├── anonymizer.py├── fmc_client.py├── scoring.py├── report.py└── main.py

Создаем сервис в облаке

Теперь надо создать инференс-сервис. Идем в панель управления → Продукты → Foundation Models Catalog и выбираем:

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

  • Модель: t-pro-it-2-1,

  • Инстансы: 1,

  • Масштабирование: фиксированное,

  • GPU: 4 x NVIDIA L4 (24 ГБ),

  • Контекст: 16 384.

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

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

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

Для каждого инференс-сервиса используются отдельные API-ключи. При создании первый ключ выпускается автоматически.

Создаем .env-файл и заполняем его полученными кредами:

FMC_ENDPOINT=https://6a42e1c7-9ac8-423e-8eb1-be4dd09c66e5.wc.ru-7.inference.selcloud.ruFMC_API_KEY=1234567890FMC_MODEL=t-pro-it-2-1

И сразу добавляем его в .gitignore, API-ключу делать в Git нечего.

.env.venv/__pycache__/results/

Перед написанием приложения отправляем банальный запрос через curl, чтобы сразу все проверить и не городить огород с багами.

curl https://6a42e1c7-9ac8-423e-8eb1-be4dd09c66e5.wc.ru-7.inference.selcloud.ru/v1/completions \-H "Authorization: Bearer 1234567890" \-H "Content-Type: application/json" \-d '{  "model": "t-pro-it-2-1",  "prompt": "Say this is a test",  "temperature": 0,  "max_tokens": 7}'

Тестовый запрос прошел, давайте сделаем еще один и смажем JQ:

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

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

FMC поддерживает Chat API по адресу /v1/chat/completions, Bearer-авторизацию и ответы в формате OpenAI API. Эндпоинт, API-ключ и имя модели можно скопировать из панели управления.

Если получаете 401 Unauthorized, то проверяйте креды. Вот для этого тесты и нужны, чтобы потом вся задумка не превратилась в тыкву.

Теперь устанавливаем зависимости:

python3 -m venv .venvsource .venv/bin/activate

Далее устанавливаем пакеты:

pip install pymupdf requests pydantic python-dotenv pyyaml jinja2

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

  • pymupdf извлекает текст из PDF,

  • requests обращается к FMC,

  • pydantic проверяет ответ модели,

  • pyyaml читает описание вакансии,

  • jinja2 формирует HTML-отчет,

  • python-dotenv загружает настройки.

Дальше будет много кода. На роль Python-разработчика я не претендую, поэтому для профильного специалиста код может выглядеть как лапша. В любом случае код рабочий и меня он вполне устраивает.

Создаем src/pdf_parser.py:

from __future__ import annotationsimport refrom pathlib import Pathfrom typing import castimport pymupdf as fitzclass PdfTextError(RuntimeError):passdef normalize_text(text: str) -> str:text = text.replace("\u00a0", " ")text = text.replace("\u200b", "")text = text.replace("\xad", "")lines = [    re.sub(r"[ \t]+", " ", line).strip()    for line in text.splitlines()]text = "\n".join(lines)text = re.sub(r"\n{3,}", "\n\n", text)return text.strip()def extract_pdf_text(pdf_path: Path) -> str:if not pdf_path.is_file():    raise FileNotFoundError(        f"PDF не найден: {pdf_path}"    )pages: list[str] = []try:    with fitz.open(pdf_path) as document:        if document.page_count == 0:            raise PdfTextError(                "В PDF нет страниц"            )        for page_index in range(            document.page_count        ):            page_number = page_index + 1            page = document.load_page(                page_index            )            page_text = cast(                str,                page.get_text(                    "text",                    sort=True,                ),            )            page_text = normalize_text(                page_text            )            if not page_text:                continue            pages.append(                f"--- Страница {page_number} ---\n"                f"{page_text}"            )except fitz.FileDataError as error:    raise PdfTextError(            f"Не удалось прочитать PDF: {error}"    ) from errorresult = "\n\n".join(pages).strip()if len(result) < 300:    raise PdfTextError(        "Из PDF извлечено слишком мало текста. "        "Возможно, документ является сканом "        "без текстового слоя."    )return result

Проверяем:

from pathlib import Pathfrom src.pdf_parser import extract_pdf_texttext = extract_pdf_text(Path("resumes/resume-001.pdf"))print(text[:1500])

В результате получаем:

--- Страница 1 --- DevOps-инженер Опыт работы: 4 года ООО «Рога и копыта» DevOps-инженер Май 2022 — настоящее время Администрировал 60 виртуальных машин Ubuntu и Debian.

PyMuPDF не выполняет OCR. Если PDF состоит из отсканированных изображений, текстового слоя там может не быть. В этом случае я не отправляю пустой документ в модель, а помещаю резюме в очередь ручной проверки:

extraction_quality: poorverdict: manual_review

Теоретически можно подключить Tesseract или отдельную OCR-модель, но я предпочитаю, чтобы под один проект была одна модель. Поэтому в эксперименте я использую PDF с нормальным текстовым слоем.

ИИ‑роутер — доступ к 300+ моделям из одной панели

Подключите OpenAI‑совместимый шлюз по единому API‑ключу. Настраивайте сквозную аналитику, отслеживайте лимиты и квотируйте ресурсы.

Запустить ИИ‑роутер →

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

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

  • имя;

  • телефон;

  • почта;

  • адрес;

  • дата рождения;

  • ссылки на соцсети.

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

Создаем src/anonymizer.py:

from __future__ import annotations import reEMAIL_PATTERN = re.compile(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b",flags=re.IGNORECASE,)PHONE_PATTERN = re.compile(r"(?<!\d)(?:\+7|8)"r"[\s\-()]*(?:\d[\s\-()]*){10}(?!\d)")URL_PATTERN = re.compile(r"(?:https?://|www\.|t\.me/)\S+",flags=re.IGNORECASE,)SENSITIVE_LINE_PATTERN = re.compile(r"^(?:"r"дата рождения|"r"возраст|"r"пол|"r"семейное положение|"r"гражданство|"r"адрес|"r"место проживания"r")\s*:.*$",flags=re.IGNORECASE | re.MULTILINE,)def anonymize_resume(text: str) -> str:result = EMAIL_PATTERN.sub(    "[EMAIL REMOVED]",    text,)result = PHONE_PATTERN.sub(    "[PHONE REMOVED]",    result,)result = URL_PATTERN.sub(    "[LINK REMOVED]",    result,)result = SENSITIVE_LINE_PATTERN.sub(    "[PERSONAL DATA REMOVED]",    result,)return result

Оговорюсь, что здесь у меня демонстрационный фильтр, а не продакшен-система обезличивания. Фильтр удалит очевидный телефон и email, но может не распознать имя или необычно записанный адрес. В продакшене такой этап придется усиливать и отдельно проверять с юристами и специалистами по ИБ. Но для синтетических документов этого достаточно.

Описываем ожидаемый ответ

Создаем src/models.py:

from __future__ import annotationsfrom typing import Literalfrom pydantic import (BaseModel,Field,field_validator,)CriterionStatus = Literal["confirmed","partial","not_found","contradicted",]class CriterionAssessment(BaseModel):id: strlevel: int = Field(    ge=0,    le=4,)status: CriterionStatusevidence: list[str] = Field(    default_factory=list,    max_length=5,)comment: str = Field(    min_length=1,    max_length=800,)@field_validator("evidence")@classmethoddef clean_evidence(    cls,    values: list[str],) -> list[str]:    result = []    for value in values:        value = value.strip()        if value and value not in result:            result.append(value)    return resultclass ModelAssessment(BaseModel):candidate_summary: str = Field(    min_length=1,    max_length=1000,)extraction_quality: Literal[    "good",    "partial",    "poor",]criteria: list[CriterionAssessment]missing_required: list[str] = Field(    default_factory=list,)unclear_points: list[str] = Field(    default_factory=list,    max_length=10,)interview_questions: list[str] = Field(    default_factory=list,    max_length=10,)short_reason: str = Field(    min_length=1,    max_length=1200,)

Библиотека pydantic проверяет:

  • наличие всех полей,

  • типы значений,

  • диапазон уровня от 0 до 4,

  • допустимые статусы,

  • максимальное количество доказательств и вопросов.

Модель может вернуть вот такой результат:

{   "level": "отлично"}

Тогда проверка завершится ошибкой.

Создаем промпт

Теперь пришло время создать промт. Создаем файл src/fmc_client.py и сначала пишем системную инструкцию:

SYSTEM_PROMPT = """Ты анализируешь соответствие профессионального опытаиз резюме требованиям конкретной вакансии.Это не оценка личности кандидата и не окончательноерешение о найме или отказе.Оценивай только сведения, которые явно присутствуютв переданном тексте резюме.Правила:1. Не учитывай имя, пол, возраст, семейное положение,   гражданство, адрес, фотографию и другие личные признаки.2. Не придумывай опыт, которого нет в резюме.3. Упоминание технологии без описания ее применения   дает не более 1 балла.4. Статус not_found означает только отсутствие   подтверждения в тексте. Он не означает, что кандидат   точно не обладает навыком.5. Учитывай эквивалентные инструменты и подходы.   Например:   - SaltStack подтверждает опыт управления конфигурацией;   - Argo CD подтверждает опыт автоматизации деплоя и GitOps;   - Zabbix подтверждает опыт мониторинга.6. Для каждого критерия приводи короткие дословные   доказательства из резюме.7. Не давай рекомендации по улучшению резюме.8. Не принимай решение о найме или автоматическом отказе.9. Не рассчитывай общий балл.10. Верни только JSON без Markdown, вступления и пояснений.Шкала уровня:0 — в тексте нет подтверждения;1 — технология только упомянута;2 — подтвержден базовый практический опыт;3 — подтвержден самостоятельный production-опыт;4 — подтвержден сложный опыт, ответственностьза результат или измеримое достижение.""".strip()

Пользовательский промпт будет содержать:

КРИТЕРИИ ВАКАНСИИ
…
ТЕКСТ РЕЗЮМЕ
…
ОЖИДАЕМАЯ JSON-СХЕМА
…

Так модель знает, какие именно поля мы будем проверять.

Запускаем FMC

Дополняем файл src/fmc_client.py:

from __future__ import annotationsimport jsonimport osimport reimport timefrom typing import Anyimport requestsfrom src.models import ModelAssessmentclass FmcError(RuntimeError):passdef extract_json_object(raw_content: str,) -> dict[str, Any]:raw_content = raw_content.strip()raw_content = re.sub(    r"^```(?:json)?\s*",    "",    raw_content,    flags=re.IGNORECASE,)raw_content = re.sub(    r"\s*```$",    "",    raw_content,)start = raw_content.find("{")end = raw_content.rfind("}")if start == -1 or end == -1 or end < start:    raise FmcError(        "Модель не вернула JSON-объект"    )try:    parsed = json.loads(        raw_content[start:end + 1]    )except json.JSONDecodeError as error:    raise FmcError(        f"Некорректный JSON: {error}"    ) from errorif not isinstance(parsed, dict):    raise FmcError(        "Корнем ответа должен быть JSON-объект"    )return parsedclass FmcClient:def __init__(self) -> None:    self.endpoint = os.environ[        "FMC_ENDPOINT"    ].rstrip("/")    self.api_key = os.environ[        "FMC_API_KEY"    ]    self.model = os.environ[        "FMC_MODEL"    ]    self.session = requests.Session()    self.session.headers.update(        {            "Authorization": (                f"Bearer {self.api_key}"            ),            "Content-Type": (                "application/json"            ),        }    )def analyze(    self,    vacancy: dict[str, Any],    resume_text: str,) -> tuple[ModelAssessment, float]:    response_schema = (        ModelAssessment.model_json_schema()    )    user_prompt = (        "КРИТЕРИИ ВАКАНСИИ:\n"        + json.dumps(            vacancy,            ensure_ascii=False,            indent=2,        )        + "\n\nТЕКСТ РЕЗЮМЕ:\n"        + resume_text        + "\n\nОЖИДАЕМАЯ JSON-СХЕМА:\n"        + json.dumps(            response_schema,            ensure_ascii=False,            indent=2,        )    )    payload = {        "model": self.model,        "temperature": 0,        "max_tokens": 700,        "messages": [            {                "role": "system",                "content": SYSTEM_PROMPT,            },            {                "role": "user",                "content": user_prompt,            },        ],        }    started = time.perf_counter()    response = self.session.post(        (            f"{self.endpoint}"            "/v1/chat/completions"        ),        json=payload,        timeout=240,    )    elapsed = (        time.perf_counter()        - started    )    if not response.ok:        raise FmcError(            f"FMC API error "            f"{response.status_code}: "            f"{response.text}"        )    try:        response_payload = (            response.json()        )        raw_content = (            response_payload["choices"][0]            ["message"]["content"]        )    except (            ValueError,        KeyError,        IndexError,        TypeError,    ) as error:        raise FmcError(            "Не удалось разобрать ответ FMC"        ) from error    parsed = extract_json_object(        raw_content    )    assessment = (        ModelAssessment.model_validate(            parsed        )    )    return assessment, elapsed

Параметр temperature я устанавливаю в ноль, чтобы снизить случайность ответа.

Но даже при нулевой температуре полная повторяемость не гарантируется. Поэтому позже отдельно проверим стабильность на нескольких запусках.

Не доверяем цитатам модели

В ответе модель должна приводить доказательства из резюме. Но что мешает ей меня обмануть или вообще придумать красивую цитату? Чтобы избежать этого, создаем функцию:

import refrom dataclasses import dataclassfrom src.models import ModelAssessment@dataclassclass EvidenceCheck:total: intmatched: intmissing: list[str]def normalize_for_matching(text: str,) -> str:text = text.lower()text = text.replace("ё", "е")text = re.sub(r"\s+", " ", text)return text.strip()def check_evidence(resume_text: str,assessment: ModelAssessment,) -> EvidenceCheck:normalized_resume = (    normalize_for_matching(        resume_text    ))total = 0matched = 0missing: list[str] = []for criterion in assessment.criteria:        for evidence in criterion.evidence:        total += 1        normalized_evidence = (            normalize_for_matching(                evidence            )        )        if (            normalized_evidence            in normalized_resume        ):            matched += 1        else:            missing.append(evidence)return EvidenceCheck(    total=total,    matched=matched,    missing=missing,)

Если модель вернула Самостоятельно спроектировал Kubernetes-кластер, а в резюме такой строки нет, доказательство помечается как неподтвержденное. Это не идеальная проверка: модель может убрать знак препинания или сократить цитату. Но для первого варианта я специально сделал дословные фрагменты, чтобы результат можно было проверить обычным поиском по строке.

Считаем итоговый балл

Создаем файл src/scoring.py:

from __future__ import annotationsfrom typing import Anyfrom src.models import ModelAssessmentdef calculate_result(vacancy: dict[str, Any],assessment: ModelAssessment,) -> dict[str, Any]:criteria_config = {    item["id"]: item    for item in vacancy["criteria"]}model_results = {    item.id: item    for item in assessment.criteria}weighted_score = 0.0required_gaps: list[str] = []criterion_results = []for criterion_id, config in (    criteria_config.items()):    model_result = (        model_results.get(            criterion_id        )    )    if model_result is None:        level = 0        status = "not_found"        evidence: list[str] = []        comment = (            "Модель не вернула "            "оценку критерия"        )    else:        level = model_result.level        status = model_result.status        evidence = model_result.evidence        comment = model_result.comment    weight = int(config["weight"])    points = (        weight * level / 4    )    weighted_score += points    if (        bool(config["required"])        and level < 2    ):        required_gaps.append(            criterion_id        )    criterion_results.append(        {            "id": criterion_id,            "title": config["title"],            "required": bool(                config["required"]            ),            "weight": weight,            "level": level,            "status": status,            "points": round(                points,                2,            ),            "evidence": evidence,            "comment": comment,        }    )fit_score = round(    weighted_score)if (    assessment.extraction_quality    != "good"):    verdict = "manual_review"elif (    fit_score >= 75    and not required_gaps):    verdict = "match"elif (    fit_score >= 50    and len(required_gaps) <= 1):    verdict = "manual_review"else:    verdict = "no_match"return {    "fit_score": fit_score,    "verdict": verdict,    "candidate_summary": (        assessment.candidate_summary    ),    "criteria": criterion_results,    "required_gaps": required_gaps,    "unclear_points": (        assessment.unclear_points    ),    "interview_questions": (        assessment.interview_questions    ),    "short_reason": (        assessment.short_reason    ),    "extraction_quality": (        assessment.extraction_quality    ),}

Как трактовать результаты:

  • match — в резюме достаточно подтверждений, чтобы посмотреть кандидата в приоритетном порядке.

  • manual_review — есть спорные места, проблемы с извлечением текста или неполное покрытие требований. Такое резюме нужно проверить вручную.

  • no_match — в документе недостаточно подтверждений по текущему профилю вакансии.

В общем, no_match не должен автоматически отправлять кандидату отказ. Это результат сопоставления текста с конкретной рубрикой, который HR может проверить и изменить.

Загружаем вакансию

from __future__ import annotationsfrom pathlib import Pathfrom typing import Anyimport yamldef load_vacancy(path: Path,) -> dict[str, Any]:with path.open(    encoding="utf-8") as file:    vacancy = yaml.safe_load(        file    )if not isinstance(vacancy, dict):    raise ValueError(        "Вакансия должна быть "        "YAML-объектом"    )criteria = vacancy.get(    "criteria")if not isinstance(criteria, list):    raise ValueError(        "В вакансии отсутствует "        "список criteria"    )weight_sum = sum(    int(item["weight"])    for item in criteria)if weight_sum != 100:    raise ValueError(        "Сумма весов критериев "        f"должна быть 100, сейчас: "        f"{weight_sum}"    )return vacancy

Проверка суммы весов спасет от ситуации, когда после добавления нового критерия максимальный результат становится 115 из 100. Нам такое, естественно, не подходит, поэтому тут лучше переборщить со внимательностью к деталям.

Собираем HTML-отчет

Создаем templates/report.html:

<!doctype html><html lang="ru"><head>  <meta charset="utf-8">  <metaname="viewport"content="width=device-width, initial-scale=1"  >  <title>{{ candidate_id }} — проверка вакансии  </title>  <style>:root {  color-scheme: dark;  --background: #080a0f;  --panel: #0d1016;  --panel-light: #151920;  --border: #303640;  --text: #e5e7eb;  --muted: #a4a9b3;  --accent: #c9ff35;  --warning: #ffca58;  --danger: #ff7070;}* {  box-sizing: border-box;}body {  margin: 0;  padding: 40px 24px;  color: var(--text);  background: var(--background);  font-family:    Inter,    system-ui,    -apple-system,    sans-serif;  line-height: 1.55;}main {  max-width: 1280px;  margin: 0 auto;}h1,h2,h3 {  margin-top: 0;}h1 {  margin-bottom: 8px;  font-size: 32px;}.muted {  color: var(--muted);    }.summary-grid {  display: grid;  grid-template-columns:    repeat(3, minmax(0, 1fr));  gap: 16px;  margin: 32px 0;}.card {  padding: 24px;  border: 1px solid var(--border);  background: var(--panel);}.card__label {  color: var(--muted);  font-size: 13px;  text-transform: uppercase;  letter-spacing: 0.08em;}.card__value {  margin-top: 10px;  color: var(--accent);  font-family: monospace;  font-size: 30px;  font-weight: 700;}table {  width: 100%;  border-collapse: collapse;  margin: 32px 0;  background: var(--panel);}th,td {  padding: 18px;  border-bottom:    1px solid var(--border);  text-align: left;  vertical-align: top;}th {  color: var(--muted);  background: var(--panel-light);  font-size: 12px;  text-transform: uppercase;  letter-spacing: 0.06em;}.points {  color: var(--accent);  font-family: monospace;  font-weight: 700;  white-space: nowrap;}.status {  display: inline-block;  padding: 4px 9px;  border: 1px solid var(--border);  font-family: monospace;  font-size: 12px;}.status--confirmed {  color: var(--accent);}.status--partial {  color: var(--warning);}.status--not_found,.status--contradicted {  color: var(--danger);}.evidence {  margin: 10px 0 0;  padding-left: 20px;  color: var(--muted);}.two-columns {  display: grid;  grid-template-columns:    repeat(2, minmax(0, 1fr));  gap: 24px;  margin-top: 32px;}.list {  margin: 0;  padding-left: 24px;}.list li + li {  margin-top: 12px;}.verdict {  color: var(--accent);  font-size: 20px;  font-weight: 700;}@media (max-width: 800px) {  .summary-grid,  .two-columns {    grid-template-columns: 1fr;  }  body {    padding: 24px 12px;  }  th,  td {    padding: 12px;  }}  </style></head><body><main>  <h1>Соответствие вакансии</h1>  <p class="muted">{{ vacancy_title }} · {{ candidate_id }}  </p>  <div class="summary-grid"><section class="card">  <div class="card__label">    Итоговый балл  </div>  <div class="card__value">        {{ result.fit_score }}/100  </div></section><section class="card">  <div class="card__label">    Результат  </div>  <div class="card__value">    {{ result.verdict }}  </div></section><section class="card">  <div class="card__label">    Время обработки  </div>  <div class="card__value">    {{ "%.2f"|format(latency) }} с  </div></section>  </div>  <section class="card"><h2>Краткая выжимка</h2><p>  {{ result.candidate_summary }}</p><p class="muted">  {{ result.short_reason }}</p>  </section>  <table><thead>  <tr>    <th>Критерий</th>    <th>Статус</th>    <th>Баллы</th>    <th>Доказательства</th>  </tr></thead><tbody>{% for criterion in result.criteria %}  <tr>    <td>      <strong>        {{ criterion.title }}      </strong>      {% if criterion.required %}        <div class="muted">          Обязательный критерий        </div>      {% endif %}    </td>    <td>      <span        class="          status          status--{{ criterion.status }}        "      >        {{ criterion.status }}      </span>      <p class="muted">        {{ criterion.comment }}      </p>    </td>    <td class="points">      {{ criterion.points }}      /      {{ criterion.weight }}        </td>    <td>      {% if criterion.evidence %}        <ul class="evidence">        {% for evidence in criterion.evidence %}          <li>{{ evidence }}</li>        {% endfor %}        </ul>      {% else %}            <span class="muted">          Подтверждение не найдено        </span>      {% endif %}    </td>  </tr>{% endfor %}</tbody>  </table>  <div class="two-columns"><section class="card">  <h2>Что надо уточнить</h2>  {% if result.unclear_points %}    <ul class="list">    {% for item in result.unclear_points %}      <li>{{ item }}</li>    {% endfor %}    </ul>  {% else %}    <p class="muted">      Явных спорных мест не найдено.    </p>  {% endif %}</section><section class="card">  <h2>Вопросы на интервью</h2>  {% if result.interview_questions %}    <ul class="list">    {% for question in result.interview_questions %}      <li>{{ question }}</li>    {% endfor %}    </ul>  {% else %}    <p class="muted">      Вопросы не сформированы.    </p>  {% endif %}</section>  </div></main></body></html>

Рендерим отчет

Создаем src/report.py:

from __future__ import annotationsfrom pathlib import Pathfrom typing import Anyfrom jinja2 import (Environment,FileSystemLoader,select_autoescape,)def render_report(candidate_id: str,vacancy_title: str,result: dict[str, Any],latency: float,output_path: Path,) -> None:environment = Environment(    loader=FileSystemLoader(        "templates"    ),    autoescape=select_autoescape(        ["html", "xml"]    ),)template = environment.get_template(    "report.html.j2")html = template.render(    candidate_id=candidate_id,    vacancy_title=vacancy_title,    result=result,    latency=latency,)output_path.parent.mkdir(    parents=True,    exist_ok=True,)output_path.write_text(    html,    encoding="utf-8",)

Собираем все в один запуск

Создаем src/main.py:

from __future__ import annotationsimport argparseimport csvimport jsonfrom pathlib import Pathfrom typing import Anyfrom dotenv import load_dotenvfrom src.anonymizer import (anonymize_resume,)from src.fmc_client import (FmcClient,FmcError,)from src.pdf_parser import (PdfTextError,extract_pdf_text,)from src.report import render_reportfrom src.scoring import (calculate_result,)def load_vacancy(path: Path,) -> dict[str, Any]:import yamlwith path.open(    encoding="utf-8") as file:    vacancy = yaml.safe_load(        file    )if not isinstance(vacancy, dict):    raise ValueError(        "Вакансия должна быть "        "YAML-объектом"    )criteria = vacancy.get(    "criteria")    if not isinstance(criteria, list):    raise ValueError(        "В вакансии нет criteria"    )weight_sum = sum(    int(item["weight"])    for item in criteria)if weight_sum != 100:    raise ValueError(        "Сумма весов должна быть 100, "        f"сейчас: {weight_sum}"    )return vacancydef save_json(path: Path,payload: dict[str, Any],) -> None:path.parent.mkdir(    parents=True,    exist_ok=True,)path.write_text(    json.dumps(        payload,        ensure_ascii=False,        indent=2,    ),    encoding="utf-8",)def write_summary_csv(rows: list[dict[str, Any]],output_path: Path,) -> None:output_path.parent.mkdir(    parents=True,    exist_ok=True,)with output_path.open(    "w",    encoding="utf-8",    newline="",) as file:    writer = csv.DictWriter(        file,        fieldnames=[            "candidate_id",            "fit_score",            "verdict",            "latency_seconds",            "required_gaps",            "error",        ],    )    writer.writeheader()    writer.writerows(rows)def process_resume(pdf_path: Path,vacancy: dict[str, Any],client: FmcClient,results_dir: Path,) -> dict[str, Any]:candidate_id = pdf_path.stemtry:    raw_text = extract_pdf_text(        pdf_path    )    clean_text = anonymize_resume(        raw_text    )    assessment, latency = (        client.analyze(            vacancy=vacancy,            resume_text=clean_text,        )    )    result = calculate_result(        vacancy=vacancy,        assessment=assessment,    )    result["candidate_id"] = (        candidate_id    )    result["source_file"] = (        pdf_path.name    )    result["latency_seconds"] = (        round(latency, 3)    )    save_json(        (            results_dir            / f"{candidate_id}.json"        ),        result,        )    render_report(        candidate_id=candidate_id,        vacancy_title=vacancy["title"],        result=result,        latency=latency,        output_path=(            results_dir            / f"{candidate_id}.html"        ),    )    return {        "candidate_id": candidate_id,        "fit_score": result["fit_score"],        "verdict": result["verdict"],        "latency_seconds": round(            latency,            3,        ),        "required_gaps": ",".join(            result["required_gaps"]        ),        "error": "",    }except (    PdfTextError,    FmcError,    ValueError,) as error:    return {        "candidate_id": candidate_id,        "fit_score": "",        "verdict": "manual_review",        "latency_seconds": "",        "required_gaps": "",        "error": str(error),    }def main() -> None:load_dotenv()parser = argparse.ArgumentParser()parser.add_argument(    "--vacancy",    type=Path,    required=True,)parser.add_argument(    "--resumes",    type=Path,    required=True,)parser.add_argument(    "--results",    type=Path,    default=Path("results"),)args = parser.parse_args()vacancy = load_vacancy(    args.vacancy)pdf_files = sorted(    args.resumes.glob("*.pdf"))if not pdf_files:    raise RuntimeError(        "В каталоге нет PDF"    )client = FmcClient()summary_rows = []for number, pdf_path in enumerate(    pdf_files,    start=1,):    print(        f"[{number}/{len(pdf_files)}] "        f"{pdf_path.name}"    )    row = process_resume(        pdf_path=pdf_path,        vacancy=vacancy,        client=client,        results_dir=args.results,    )    summary_rows.append(row)    print(        f"  verdict={row['verdict']} "        f"score={row['fit_score']}"    )write_summary_csv(    summary_rows,    args.results / "summary.csv",)if __name__ == "__main__":main()

Итого получаем такую структуру:

Запускаем сервис

source .venv/bin/activatepython3 -m src.main --vacancy vacancies/middle-devops.yaml --resumes resumes --results results

Спустя пару минут, в папке results видим новые файлы и сам результат в консоли:

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

Не стал бы утверждать, что это прям готовая ATS, которую можно внедрять к себе в CRM и пачками отсеивать вайбкодеров.

Вот что получается по кандидату №5, который набрал 78 баллов:

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

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

Итого: 2 совпадения из 5 (40%). два кандидата получили match, два отправлены на ручную проверку и один получил no_match. При этом модель не спрятала потенциально подходящих кандидатов в no_match, выбрав для спорных случаев более осторожный manual_review. Идем уверенно!

Самые интересные здесь два последних резюме. Кандидат №4 проверяет, даст ли модель высокий балл за красивый список технологий без доказательств. Кандидат №5 показывает, способна ли модель учитывать альтернативный стек, а не искать только точные совпадения с вакансией.

Проверяем стабильность

Одно и то же резюме прогоняю пять раз:

for run in {1..5}; do  mkdir -p "results/run-$run"  python3 -m src.main \--vacancy vacancies/middle-devops.yaml \--resumes resumes-one \--results "results/run-$run"done

Сравниваю итоговый балл, verdict, уровни критериев, доказательства и вопросы для интервью.

Результат полностью стабилен:

  • разброс 0 баллов,

  • совпадение вердиктов 5 из 5,

  • уровни всех критериев совпали,

  • доказательства совпали дословно,

  • вопросы для интервью совпали дословно.

Тут важно, что если балл гуляет на два-три пункта, с этим еще можно жить. Если один запуск говорит match, а другой no_match, такой инструмент использовать нельзя, пока не будет стабильности. Тоже учитывай этот момент.

Что еще можно измерить

Валидность JSON

valid_json_rate = успешно проверенные ответы / все ответы. То есть, например,

при 49 валидных ответах из 50 valid_json_rate = 98%.

Достоверность доказательств

evidence_match_rate = дословно найденные цитаты / все цитаты. Если модель придумала цитату, результат должен быть помечен для ручной проверки.

Время обработки

Для каждого PDF сохраняется latency_seconds:

Оценка времени для 400 резюме

При последовательной обработке общее время будет равно среднему времени на проверку одного резюме × 400. То есть если одно резюме обрабатывается 12 секунд, то 12 × 400 = 4 800 секунд ≈ 80 минут. Но это пока только арифметическая оценка.

Реальное время зависит от размера резюме, конфигурации инференс-сервиса, нагрузки, количества одновременных запросов, лимитов сервиса и повторных запросов после ошибок.

После последовательного теста можно аккуратно добавить два или четыре worker-потока и сравнить throughput. Сразу запускать 50 параллельных запросов только потому, что это все надо срочно и вчера — плохая идея.

Ложные отрицательные результаты

Главный вопрос для такой системы — не спрятали ли мы сильного кандидата в хвост выдачи? Если HR вручную отметил 20 подходящих кандидатов, а система поместила 18 из них в приоритетную очередь, то recall = 18 / 20 = 0.9.

В этом сценарии лучше показать человеку несколько лишних резюме, чем потерять одного подходящего специалиста. Поэтому пороги я бы настраивал в сторону manual_review, а не агрессивного no_match. Если опасаетесь, что это будет слишком затратно для бюджета на инфраструктуру, то нет. FMC использует модель оплаты по потреблению облачных ресурсов, а не токенов. То есть средства списываются за ресурсы инференс-сервера: GPU, vCPU, RAM и диск, а количество токенов отдельно не тарифицируется.

Здесь еще важный момент в том, что модель оценивает не только ключевые слова. Это важно, чтобы сильный SRE-инженер с SaltStack и Argo CD не получил ноль баллов за отсутствие Ansible и GitLab CI. К тому же, по каждому критерию есть объяснение. Так что благодаря настройке в сторону manual_review повышается шанс, что HR увидит не абстрактные «88 баллов», а конкретные фрагменты из резюме. Например, что кандидат снизил MTTR с 50 до 20 минут.

Итоговый балл считается обычным кодом, LLM не занимается математикой и не определяет пороги. Уровни критериев всегда дают одинаковый результат. PDF не отправляется в модель целиком, сначала локально извлекается текст, затем удаляются очевидные персональные данные. Модель получает только очищенную текстовую версию.

Tools не нужны, их отсутствие вообще не мешает. Все действия заранее определены:

Где начались неожиданности

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

Ключевые слова продолжают влиять на результат. Даже с хорошим промптом модель может завышать кандидата, у которого перечислено 20 технологий. Поэтому в инструкции явно написано: Упоминание без применения — не больше 1 балла. Именно для этого нужен отдельный тест с keyword stuffing, на случай, когда кандидат пытается обмануть подобную систему распознавания.

Еще модель иногда переформулирует доказательства, поэтому я требую дословные цитаты. Но тут выясняется, что LLM может слегка переписать исходную фразу. Тогда простая проверка подстроки считает цитату неподтвержденной. Можно перейти к нечеткому сравнению, но я бы сначала оставил строгий режим. Лучше получить лишнее предупреждение, чем принять придуманное доказательство за настоящее.

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

Чего нет в этом сервисе

Описанный мной сервис:

  • не отправляет отказы,

  • не пишет кандидатам,

  • не назначает встречи,

  • не меняет статусы в ATS,

  • не удаляет резюме,

  • не принимает решение о найме,

  • не оценивает личность человека,

  • не анализирует возраст, пол или фотографию.

В реальном использовании HR по итогу просто получает список кандидатов и открывает оригинальные документы. Моя автоматизация помогает добраться до сильного кандидатов быстрее, но не убирает человека из процесса.

Итоги сего мероприятия

Начиналось все с простой картины: одному HR нужно было за день разобрать 400 резюме в PDF, не отсеять сильных кандидатов и не пропустить слабых. 

Вся механика выполняется на обычном Python. Модель отвечает только за смысловую часть: ищет в резюме подтверждения профессионального опыта и сопоставляет их с требованиями вакансии.

Я выбрал text-to-text-модель, создал сервис и получил готовый эндпоинт, не разворачивая GPU-инфраструктуру самостоятельно.

Самое важное — я не старался превратить LLM в цифрового HR-директора. Она не решает судьбу кандидата, но помогает человеку разобрать входящий поток и показывает, на какие резюме стоит посмотреть в первую очередь. По-моему, это как раз нормальная автоматизация — не заменить специалиста, а забрать у него рутину, от которой он уже плачет кровавыми слезами.

Конец!

ссылка на оригинал статьи https://habr.com/ru/articles/1085956/