Каждый, кто искал работу, знает: рутинная адаптация резюме и написание сопроводительных писем под каждую вакансию — это очень трудозатратно и выжигает всю мотивацию. Идея автоматизировать этот процесс с помощью LLM витает в воздухе. Но одно дело — набросать простенький скрипт, который склеивает пару строк через API GPT, и совсем другое — построить надежную, расширяемую систему, которую можно назвать полноценным AI-агентом.
Сегодня мы препарируем готовый open-source проект Auto_Jobs_Applier_AIHawk
. Его прелесть не в сиюминутном вау-эффекте, а в продуманной архитектуре. На его примере мы увидим, как отделить данные от логики, логику от представления, и как правильно "общаться" с LLM, чтобы получать стабильный и предсказуемый результат.
Общая архитектура: Принцип разделения ответственности
Если посмотреть на проект с высоты птичьего полета, можно увидеть не хаотичный набор скриптов, а четкое разделение на логические блоки. Это классический принцип Separation of Concerns (разделение ответственности), который лежит в основе любого качественного ПО.
Вот ключевые компоненты нашего агента:
- Конфигурация и данные: YAML-файлы, которые хранят всю информацию о пользователе (резюме, предпочтения) и секреты (API-ключи). Это "память" и "настройки" нашего агента.
- Модели данных (схемы): Python-классы (с использованием
Pydantic
), которые описывают структуру данных из YAML. Это контракт, гарантирующий, что по всей системе мы работаем с валидными и предсказуемыми объектами, а не с хаосом словарей. - "Мозг" (взаимодействие с LLM): Модули, отвечающие за все общение с большими языковыми моделями. Сюда входит парсинг вакансий, формирование промптов и генерация текстового контента.
- Генератор документов: Компонент, который берет сгенерированный LLM контент, "одевает" его в HTML-шаблон со стилями (CSS) и конвертирует в финальный PDF-документ.
- Оркестратор (фасад): Главный управляющий модуль, который координирует работу всех остальных частей. Он предоставляет простой и понятный интерфейс для запуска всего процесса, скрывая внутреннюю сложность.
Такая структура делает систему гибкой. Хотите добавить нового LLM-провайдера? Меняем только "мозг". Новый дизайн резюме? Добавляем CSS-файл в генератор документов. Система не ломается от каждого изменения.
А теперь давайте погрузимся в каждый из этих блоков.
Конфигурация и данные — фундамент агента
Любой AI-агент начинается не с промптов, а с данных. Если данные организованы плохо, весь проект превратится в хрупкую конструкцию. В AIHawk
этот фундамент заложен грамотно.
Данные пользователя
Вместо того чтобы размазывать информацию по коду или базе данных, вся пользовательская информация лежит в data_folder/
в виде YAML-файлов:
plain_text_resume.yaml
: Полная информация для резюме — от контактов до опыта работы и проектов.work_preferences.yaml
: Предпочтения по поиску работы (удаленка, уровень должности и т.д.).secrets.yaml
: API-ключи и другие секреты.
Почему YAML?
- Человекочитаемость: Его легко просматривать и редактировать даже без навыков программирования.
- Структурированность: Вложенная структура идеально подходит для описания сложных сущностей вроде опыта работы или образования.
- Отделение от кода: Пользовательские данные не захардкожены. Можно легко подсунуть агенту другой набор файлов и получить результат для другого человека.
# Пример из plain_text_resume.yaml
personal_information:
name: "solid"
surname: "snake"
email: "hi@gmail.com"
github: "https://github.com/lol"
linkedin: "https://www.linkedin.com/in/thezucc/"
experience_details:
- position: "X"
company: "Y."
employment_period: "06/2019 - Present"
location: "San Francisco, CA"
key_responsibilities:
- responsibility: "Developed web applications using React and Node.js"
skills_acquired:
- "React"
- "Node.js"
Модели данных: Pydantic для валидации и структуры
Просто читать YAML — это полдела. Настоящая магия начинается, когда мы превращаем эти данные в строго типизированные Python-объекты. За это отвечает src/resume_schemas/resume.py
. Вместо того чтобы работать со словарями и постоянно рисковать опечаткой в ключе ('emial'
вместо 'email'
), проект использует Pydantic
.
# src/resume_schemas/resume.py (упрощенно)
from pydantic import BaseModel, EmailStr, HttpUrl
from typing import List, Optional
class PersonalInformation(BaseModel):
name: Optional[str]
surname: Optional[str]
email: Optional[EmailStr]
github: Optional[HttpUrl] = None
linkedin: Optional[HttpUrl] = None
class ExperienceDetails(BaseModel):
position: Optional[str]
company: Optional[str]
# ... и другие поля
class Resume(BaseModel):
personal_information: Optional[PersonalInformation]
experience_details: Optional[List[ExperienceDetails]] = None
# ...
def __init__(self, yaml_str: str):
data = yaml.safe_load(yaml_str)
super().__init__(**data)
Что это дает?
- Автоматическая валидация:
Pydantic
сам проверит, чтоemail
— это валидный email, аgithub
— корректный URL. Если в YAML-файле ошибка, мы получим понятное исключение на самом старте, а не странное поведение LLM в середине процесса. - Подсказки типов и автодополнение: В IDE мы получаем автодополнение (
resume.personal_information.name
), что резко снижает количество ошибок и упрощает разработку. - Надежность: Мы передаем по системе не "какой-то словарь", а объект
Resume
, и можем быть уверены в его структуре.
Мозг агента: парсинг и промпт-инжиниринг
Как заставить LLM думать так, как нужно? Этот блок можно разделить на три ключевых процесса:
- Понимание: Извлечение структурированной информации из хаоса HTML-страницы вакансии.
- Генерация: Создание текста для резюме и сопроводительных писем на основе данных пользователя и понятой информации о вакансии.
- Гибкость: Архитектурные решения, позволяющие легко менять LLM-провайдеров.
Извлечение смысла из хаоса: RAG для парсинга вакансий
Самая сложная задача — понять, что написано в вакансии. Страницы с вакансиями — это месиво из HTML, CSS и JavaScript. Просто скормить весь HTML-код в LLM — плохая идея по трем причинам:
- Ограничение контекста: У большинства моделей есть лимит на количество токенов. Длинная страница просто не влезет.
- "Шум": 95% HTML — это теги, стили, скрипты и прочий мусор, который не несет смысловой нагрузки для нашей задачи, но сбивает модель с толку.
- Стоимость: Отправлять тысячи "мусорных" токенов в API — дорого и неэффективно.
Разработчики AIHawk
пошли куда более умным путем, реализовав простой, но эффективный пайплайн в духе RAG (Retrieval-Augmented Generation). Проще говоря, вместо того чтобы заставлять LLM читать всю "книгу" (HTML-страницу), мы сначала находим в ней самые релевантные абзацы, а уже потом просим LLM их прочитать и ответить на наш вопрос.
Вот как это работает в src/libs/resume_and_cover_builder/llm/llm_job_parser.py
:
- Разделение на части (Chunking): Весь текст со страницы с помощью
TokenTextSplitter
изLangChain
нарезается на небольшие, семантически связанные фрагменты (чанки). Это позволяет работать с информацией порционно. - Векторизация (Embedding): Каждый чанк превращается в вектор — числовое представление его смысла. Для этого используется
OpenAIEmbeddings
. Близкие по смыслу куски текста будут иметь близкие векторы. - Индексация (Indexing): Все эти векторы сохраняются в специальной быстрой базе данных для поиска — FAISS. По сути, мы создаем свою мини-поисковую систему по тексту вакансии.
- Поиск и генерация (Retrieval & Generation): Когда нам нужно извлечь конкретную информацию (например, "Как называется компания?"), происходит следующее:
- Вопрос ("Company name") тоже превращается в вектор.
- FAISS мгновенно находит в своей базе 3-5 чанков, векторы которых наиболее близки к вектору нашего вопроса.
- Только эти, самые релевантные чанки, вместе с исходным вопросом отправляются в LLM.
# Упрощенная логика из llm_job_parser.py
def _extract_information(self, question: str, retrieval_query: str) -> str:
# 1. Найти релевантные чанки в нашей векторной базе (FAISS)
context = self._retrieve_context(retrieval_query)
# 2. Сформировать промпт с найденным контекстом
prompt_template = """
Context: {context}
Question: {question}
Answer:
"""
prompt = ChatPromptTemplate.from_template(template=prompt_template)
# 3. Отправить в LLM только нужную информацию
chain = prompt | self.llm | StrOutputParser()
result = chain.invoke({"context": context, "question": question})
return result.strip()
def extract_company_name(self) -> str:
question = "What is the company's name?"
retrieval_query = "Company name"
return self._extract_information(question, retrieval_query)
Этот подход на порядок эффективнее "тупого" скармливания HTML. Он экономит токены, повышает точность и позволяет обходить ограничения контекстного окна.
Искусство промпт-инжиниринга: как научить LLM писать резюме
После того как извлечена суть вакансии, нужно сгенерировать текст. Качество этого текста на 90% зависит от качества промпта. В проекте промпты вынесены в отдельные файлы (например, src/libs/resume_and_cover_builder/resume_job_description_prompt/strings_feder-cr.py
), что является золотым стандартом — логика отдельно, инструкции для LLM отдельно.
Давайте разберем структуру типичного промпта на примере генерации опыта работы:
# .../resume_job_description_prompt/strings_feder-cr.py
prompt_working_experience = """
# 1. Роль
Act as an HR expert and resume writer with a specialization in creating ATS-friendly resumes.
# 2. Четкая задача
Your task is to detail the work experience for a resume, ensuring it aligns with the provided job description. For each job entry, ensure you include:
1. **Company Name and Location**: ...
2. **Job Title**: ...
3. **Dates of Employment**: ...
4. **Responsibilities and Achievements**: ...
# 3. Предоставление контекста
- **My information:**
{experience_details}
- **Job Description:**
{job_description}
# 4. Форматирование вывода
- **Template to Use**
<section id="work-experience">
<h2>Work Experience</h2>
<div class="entry">
... HTML-шаблон ...
</div>
</section>
The results should be provided in html format, Provide only the html code for the resume, without any explanations...
"""
Здесь всё хорошо:
- Роль:
Act as an HR expert...
— это не просто вежливое обращение. Мы "загружаем" в модель нужный контекст и стиль. Она будет использовать профессиональную лексику и думать как рекрутер. - Четкая задача: Промпт не говорит "напиши про опыт", он дает конкретный, пошаговый алгоритм действий.
- Контекст: Самое главное — в промпт подаются и данные пользователя (
{experience_details}
), и выжимка из вакансии ({job_description}
). Это заставляет модель не просто пересказывать резюме, а адаптировать его под требования работодателя. - Форматирование вывода: Просьба вернуть результат в виде готового HTML-блока — это гениальный ход. Мы не получаем поток текста, который потом нужно парсить. Мы получаем готовый к вставке в шаблон компонент. Это избавляет от целого пласта проблем с обработкой ответа LLM.
Такая структура промптов — ключ к стабильным и предсказуемым результатам от языковой модели.
Архитектура для гибкости: паттерн "адаптер" для LLM
Что если завтра вы захотите переключиться с OpenAI на Claude, Llama через Ollama или любую другую модель? Переписывать весь код?
В src/libs/llm_manager.py
заложен элегантный архитектурный паттерн — адаптер.
Создан абстрактный базовый класс AIModel
:
# src/libs/llm_manager.py (упрощенно)
from abc import ABC, abstractmethod
class AIModel(ABC):
@abstractmethod
def invoke(self, prompt: str) -> str:
pass
А для каждого конкретного провайдера создается своя реализация этого класса:
class OpenAIModel(AIModel):
def __init__(self, api_key: str, llm_model: str):
self.model = ChatOpenAI(...)
def invoke(self, prompt: str) -> BaseMessage:
return self.model.invoke(prompt)
class ClaudeModel(AIModel):
def __init__(self, api_key: str, llm_model: str):
self.model = ChatAnthropic(...)
def invoke(self, prompt: str) -> BaseMessage:
return self.model.invoke(prompt)
class OllamaModel(AIModel):
# ...
Специальный класс AIAdapter
при инициализации выбирает, какую конкретную реализацию создать, основываясь на конфиге. В итоге весь остальной код работает с абстракцией AIModel
и вызывает один и тот же метод invoke()
, не зная (и не желая знать), какая именно модель находится "под капотом".
[!INFO] Дополнительно, реализация
LoggerChatModel
оборачивает вызовы к модели, добавляя критически важную логику для реального мира: логирование запросов, автоматические повторные попытки при сбоях и обработку превышения лимитов (RateLimitError
). Это паттерн декоратор, который добавляет функциональность, не изменяя основной код.
Такой подход делает систему гибкой и готовой к будущим изменениям.
Мы разобрались, как агент "думает". Остался последний шаг: как он облекает эти "мысли" в финальный, красивый документ.
Шаг 3. Генератор Документов: от HTML к PDF
Итак, "мозг" агента сгенерировал нам смысловые блоки будущего резюме в виде HTML-фрагментов. Но это пока лишь сырой материал. Задача этого модуля, который можно назвать "типографией" нашего агента, — взять эти фрагменты, придать им профессиональный вид и собрать в единый, готовый к отправке PDF-документ.
Здесь мы снова видим элегантное применение принципа разделения ответственности: контент, его внешний вид и финальный формат — это три разные, независимые сущности.
Разделение контента и представления: HTML, CSS и шаблоны
Вместо того чтобы заставлять LLM генерировать стили или, наоборот, вшивать HTML-разметку в Python-код, проект использует классический веб-подход.
Каркас (HTML-шаблон): В файле
src/libs/resume_and_cover_builder/config.py
лежит простейший HTML-шаблон. Это скелет будущего документа.# src/libs/resume_and_cover_builder/config.py self.html_template = """ <!DOCTYPE html> <html> <head> <style> $style_css </style> </head> <body> $body </body> </html> """
Здесь всего две переменные:
$style_css
для стилей и$body
для основного контента.Контент (HTML от LLM): Как мы уже выяснили на предыдущем шаге, "мозг" агента возвращает каждую секцию резюме (опыт, образование и т.д.) уже в виде готового HTML-блока. Эти блоки параллельно генерируются и потом просто склеиваются, чтобы сформировать содержимое переменной
$body
.Стиль (Подключаемые CSS): Вся "красота" вынесена в папку
src/libs/resume_and_cover_builder/resume_style/
. Каждый.css
файл — это отдельный, законченный дизайн для резюме.style_manager.py
— это маленький, но умный компонент, который отвечает за управление этими стилями. Он сканирует папку и, что самое интересное, извлекает название стиля и имя автора прямо из комментария в первой строке CSS-файла:/*Modern Blue$https://github.com/josylad*/ @import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600;700&display=swap'); body { font-family: 'Poppins', sans-serif; /* ... */ }
Такой подход, основанный на соглашении (convention over configuration), позволяет добавлять новые стили простым копированием файла, без необходимости регистрировать их где-либо в коде. Это делает систему легко расширяемой.
Магия конвертации: Selenium и Chrome DevTools Protocol
Теперь самый сложный технический момент: как превратить готовую HTML-страницу (собранную из каркаса, контента и стилей) в идеальный PDF?
Многие Python-библиотеки для генерации PDF (xhtml2pdf
, WeasyPrint
) отлично справляются с простым HTML, но пасуют перед сложным современным CSS (flexbox, grid, кастомные шрифты). Результат часто получается "кривым" и не соответствует тому, что мы видим в браузере.
Разработчики AIHawk
выбрали хороший способ. Они не стали заново изобретать рендеринг-движок. Они использовали тот, который уже установлен на миллиардах компьютеров — движок браузера Chrome (Blink).
Реализация в src/utils/chrome_utils.py
проста:
- С помощью
Selenium
запускается экземпляр браузера Chrome (часто в "headless" режиме, без видимого окна). - Полностью собранный HTML-код загружается в браузер. Причем делается это хитрым способом, через
data:text/html,...
, что позволяет избежать сохранения временных файлов на диске. - Выполняется команда
driver.execute_cdp_cmd("Page.printToPDF", {...})
.
[!DANGER]
execute_cdp_cmd
— это прямой мост к Chrome DevTools Protocol (CDP). Это низкоуровневый API, который позволяет управлять всеми аспектами браузера. КомандаPage.printToPDF
— это, по сути, программное нажатие на "Печать в PDF" с очень тонкими настройками полей, ориентации и качества.
Почему это лучший подход? Потому что PDF генерируется самим браузером. Как страница выглядит в Chrome, точно так же она будет выглядеть и в PDF. Это гарантирует pixel-perfect результат и идеальную поддержку любого, даже самого сложного CSS.
Сборка воедино: паттерн "Фасад"
Кто же управляет всем этим процессом? main.py
не вызывает напрямую StyleManager
, ResumeGenerator
и HTML_to_PDF
. Вместо этого он общается с одним объектом — ResumeFacade
из src/libs/resume_and_cover_builder/resume_facade.py
.
Фасад — это структурный паттерн проектирования, который предоставляет простой, единый интерфейс к сложной подсистеме.
Посмотрите на метод create_resume_pdf_job_tailored
:
# src/libs/resume_and_cover_builder/resume_facade.py (упрощенно)
def create_resume_pdf_job_tailored(self) -> tuple[bytes, str]:
# 1. Получить путь к выбранному стилю
style_path = self.style_manager.get_style_path()
# 2. Попросить генератор создать HTML с учетом вакансии
html_resume = self.resume_generator.create_resume_job_description_text(style_path, self.job.description)
# 3. Сконвертировать HTML в PDF
result = HTML_to_PDF(html_resume, self.driver)
# ... вернуть результат
return result, suggested_name
Вся сложность скрыта "за кулисами". Главный скрипт просто говорит "сделай мне резюме", и фасад сам оркеструет взаимодействие между всеми компонентами: получает стиль, запускает LLM для генерации контента, собирает HTML и командует браузеру создать PDF.
Это делает код чистым, тестируемым и легким для понимания.
Понравился материал?
Ваша поддержка — это энергия для новых статей и проектов. Спасибо, что читаете!
Заключение
Мы препарировали проект Auto_Jobs_Applier_AIHawk
и увидели, что его сила не в какой-то одной "серебряной пуле", а в грамотном сочетании классических принципов программной инженерии и современных AI-технологий.
Вот ключевые выводы, которые стоит унести с собой:
- Начинайте с данных: Четкая, валидируемая структура данных (YAML + Pydantic) — это 50% успеха. Она предотвращает баги и делает систему предсказуемой.
- Промпты — это тоже код: Выносите их в отдельные файлы. Структурируйте их с помощью ролевой игры, четких инструкций и примеров форматирования. Просите LLM возвращать данные в машинно-читаемом формате (HTML, JSON), чтобы избежать хрупкого парсинга текста.
- Не заставляйте LLM делать лишнюю работу: Используйте RAG-подход для извлечения информации из больших объемов текста. Сначала найдите релевантные фрагменты, потом задавайте вопросы. Это дешевле, быстрее и точнее.
- Проектируйте с прицелом на будущее: Используйте паттерны, такие как адаптер для LLM-провайдеров и фасад для упрощения API. Это позволит вашей системе легко эволюционировать.
- Используйте лучшие инструменты для задачи: Не пытайтесь написать PDF-рендер на Python, если можно заставить браузерный движок Blink сделать это за вас через CDP. Умный инженер не тот, кто пишет всё с нуля, а тот, кто умело комбинирует существующие мощные инструменты.
AIHawk
— это прекрасный пример того, как должен выглядеть AI-проект второго поколения. Не просто "proof-of-concept", а надежная, расширяемая и продуманная система. Изучая такие архитектуры, мы учимся строить не просто интересные игрушки, а настоящие, работающие в реальном мире AI-агенты.