Каждый, кто искал работу, знает: рутинная адаптация резюме и написание сопроводительных писем под каждую вакансию — это очень трудозатратно и выжигает всю мотивацию. Идея автоматизировать этот процесс с помощью LLM витает в воздухе. Но одно дело — набросать простенький скрипт, который склеивает пару строк через API GPT, и совсем другое — построить надежную, расширяемую систему, которую можно назвать полноценным AI-агентом.
Сегодня мы препарируем готовый open-source проект Auto_Jobs_Applier_AIHawk
. Его прелесть не в сиюминутном вау-эффекте, а в продуманной архитектуре. На его примере мы увидим, как отделить данные от логики, логику от представления, и как правильно "общаться" с LLM, чтобы получать стабильный и предсказуемый результат.
Если посмотреть на проект с высоты птичьего полета, можно увидеть не хаотичный набор скриптов, а четкое разделение на логические блоки. Это классический принцип Separation of Concerns (разделение ответственности), который лежит в основе любого качественного ПО.
Вот ключевые компоненты нашего агента:
Pydantic
), которые описывают структуру данных из YAML. Это контракт, гарантирующий, что по всей системе мы работаем с валидными и предсказуемыми объектами, а не с хаосом словарей.Такая структура делает систему гибкой. Хотите добавить нового 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"
Просто читать 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 в середине процесса.resume.personal_information.name
), что резко снижает количество ошибок и упрощает разработку.Resume
, и можем быть уверены в его структуре.Как заставить LLM думать так, как нужно? Этот блок можно разделить на три ключевых процесса:
Самая сложная задача — понять, что написано в вакансии. Страницы с вакансиями — это месиво из HTML, CSS и JavaScript. Просто скормить весь HTML-код в LLM — плохая идея по трем причинам:
Разработчики AIHawk
пошли куда более умным путем, реализовав простой, но эффективный пайплайн в духе RAG (Retrieval-Augmented Generation). Проще говоря, вместо того чтобы заставлять LLM читать всю "книгу" (HTML-страницу), мы сначала находим в ней самые релевантные абзацы, а уже потом просим LLM их прочитать и ответить на наш вопрос.
Вот как это работает в src/libs/resume_and_cover_builder/llm/llm_job_parser.py
:
TokenTextSplitter
из LangChain
нарезается на небольшие, семантически связанные фрагменты (чанки). Это позволяет работать с информацией порционно.OpenAIEmbeddings
. Близкие по смыслу куски текста будут иметь близкие векторы.# Упрощенная логика из 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. Он экономит токены, повышает точность и позволяет обходить ограничения контекстного окна.
После того как извлечена суть вакансии, нужно сгенерировать текст. Качество этого текста на 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}
). Это заставляет модель не просто пересказывать резюме, а адаптировать его под требования работодателя.Такая структура промптов — ключ к стабильным и предсказуемым результатам от языковой модели.
Что если завтра вы захотите переключиться с 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
). Это паттерн декоратор, который добавляет функциональность, не изменяя основной код.
Такой подход делает систему гибкой и готовой к будущим изменениям.
Мы разобрались, как агент "думает". Остался последний шаг: как он облекает эти "мысли" в финальный, красивый документ.
Итак, "мозг" агента сгенерировал нам смысловые блоки будущего резюме в виде HTML-фрагментов. Но это пока лишь сырой материал. Задача этого модуля, который можно назвать "типографией" нашего агента, — взять эти фрагменты, придать им профессиональный вид и собрать в единый, готовый к отправке PDF-документ.
Здесь мы снова видим элегантное применение принципа разделения ответственности: контент, его внешний вид и финальный формат — это три разные, независимые сущности.
Вместо того чтобы заставлять 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), позволяет добавлять новые стили простым копированием файла, без необходимости регистрировать их где-либо в коде. Это делает систему легко расширяемой.
Теперь самый сложный технический момент: как превратить готовую HTML-страницу (собранную из каркаса, контента и стилей) в идеальный PDF?
Многие Python-библиотеки для генерации PDF (xhtml2pdf
, WeasyPrint
) отлично справляются с простым HTML, но пасуют перед сложным современным CSS (flexbox, grid, кастомные шрифты). Результат часто получается "кривым" и не соответствует тому, что мы видим в браузере.
Разработчики AIHawk
выбрали хороший способ. Они не стали заново изобретать рендеринг-движок. Они использовали тот, который уже установлен на миллиардах компьютеров — движок браузера Chrome (Blink).
Реализация в src/utils/chrome_utils.py
проста:
Selenium
запускается экземпляр браузера Chrome (часто в "headless" режиме, без видимого окна).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-технологий.
Вот ключевые выводы, которые стоит унести с собой:
AIHawk
— это прекрасный пример того, как должен выглядеть AI-проект второго поколения. Не просто "proof-of-concept", а надежная, расширяемая и продуманная система. Изучая такие архитектуры, мы учимся строить не просто интересные игрушки, а настоящие, работающие в реальном мире AI-агенты.