Статьи

Архитектурный разбор AI-агента для генерации резюме на Python

LLM Опенсорс

Каждый, кто искал работу, знает: рутинная адаптация резюме и написание сопроводительных писем под каждую вакансию — это очень трудозатратно и выжигает всю мотивацию. Идея автоматизировать этот процесс с помощью LLM витает в воздухе. Но одно дело — набросать простенький скрипт, который склеивает пару строк через API GPT, и совсем другое — построить надежную, расширяемую систему, которую можно назвать полноценным AI-агентом.

Сегодня мы препарируем готовый open-source проект Auto_Jobs_Applier_AIHawk. Его прелесть не в сиюминутном вау-эффекте, а в продуманной архитектуре. На его примере мы увидим, как отделить данные от логики, логику от представления, и как правильно "общаться" с LLM, чтобы получать стабильный и предсказуемый результат.

Общая архитектура: Принцип разделения ответственности

Если посмотреть на проект с высоты птичьего полета, можно увидеть не хаотичный набор скриптов, а четкое разделение на логические блоки. Это классический принцип Separation of Concerns (разделение ответственности), который лежит в основе любого качественного ПО.

Вот ключевые компоненты нашего агента:

  1. Конфигурация и данные: YAML-файлы, которые хранят всю информацию о пользователе (резюме, предпочтения) и секреты (API-ключи). Это "память" и "настройки" нашего агента.
  2. Модели данных (схемы): Python-классы (с использованием Pydantic), которые описывают структуру данных из YAML. Это контракт, гарантирующий, что по всей системе мы работаем с валидными и предсказуемыми объектами, а не с хаосом словарей.
  3. "Мозг" (взаимодействие с LLM): Модули, отвечающие за все общение с большими языковыми моделями. Сюда входит парсинг вакансий, формирование промптов и генерация текстового контента.
  4. Генератор документов: Компонент, который берет сгенерированный LLM контент, "одевает" его в HTML-шаблон со стилями (CSS) и конвертирует в финальный PDF-документ.
  5. Оркестратор (фасад): Главный управляющий модуль, который координирует работу всех остальных частей. Он предоставляет простой и понятный интерфейс для запуска всего процесса, скрывая внутреннюю сложность.

Такая структура делает систему гибкой. Хотите добавить нового 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)

Что это дает?

  1. Автоматическая валидация: Pydantic сам проверит, что email — это валидный email, а github — корректный URL. Если в YAML-файле ошибка, мы получим понятное исключение на самом старте, а не странное поведение LLM в середине процесса.
  2. Подсказки типов и автодополнение: В IDE мы получаем автодополнение (resume.personal_information.name), что резко снижает количество ошибок и упрощает разработку.
  3. Надежность: Мы передаем по системе не "какой-то словарь", а объект Resume, и можем быть уверены в его структуре.

Мозг агента: парсинг и промпт-инжиниринг

Как заставить LLM думать так, как нужно? Этот блок можно разделить на три ключевых процесса:

  1. Понимание: Извлечение структурированной информации из хаоса HTML-страницы вакансии.
  2. Генерация: Создание текста для резюме и сопроводительных писем на основе данных пользователя и понятой информации о вакансии.
  3. Гибкость: Архитектурные решения, позволяющие легко менять 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:

  1. Разделение на части (Chunking): Весь текст со страницы с помощью TokenTextSplitter из LangChain нарезается на небольшие, семантически связанные фрагменты (чанки). Это позволяет работать с информацией порционно.
  2. Векторизация (Embedding): Каждый чанк превращается в вектор — числовое представление его смысла. Для этого используется OpenAIEmbeddings. Близкие по смыслу куски текста будут иметь близкие векторы.
  3. Индексация (Indexing): Все эти векторы сохраняются в специальной быстрой базе данных для поиска — FAISS. По сути, мы создаем свою мини-поисковую систему по тексту вакансии.
  4. Поиск и генерация (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...
"""

Здесь всё хорошо:

  1. Роль: Act as an HR expert... — это не просто вежливое обращение. Мы "загружаем" в модель нужный контекст и стиль. Она будет использовать профессиональную лексику и думать как рекрутер.
  2. Четкая задача: Промпт не говорит "напиши про опыт", он дает конкретный, пошаговый алгоритм действий.
  3. Контекст: Самое главное — в промпт подаются и данные пользователя ({experience_details}), и выжимка из вакансии ({job_description}). Это заставляет модель не просто пересказывать резюме, а адаптировать его под требования работодателя.
  4. Форматирование вывода: Просьба вернуть результат в виде готового 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-код, проект использует классический веб-подход.

  1. Каркас (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 для основного контента.

  2. Контент (HTML от LLM): Как мы уже выяснили на предыдущем шаге, "мозг" агента возвращает каждую секцию резюме (опыт, образование и т.д.) уже в виде готового HTML-блока. Эти блоки параллельно генерируются и потом просто склеиваются, чтобы сформировать содержимое переменной $body.

  3. Стиль (Подключаемые 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 проста:

  1. С помощью Selenium запускается экземпляр браузера Chrome (часто в "headless" режиме, без видимого окна).
  2. Полностью собранный HTML-код загружается в браузер. Причем делается это хитрым способом, через data:text/html,..., что позволяет избежать сохранения временных файлов на диске.
  3. Выполняется команда 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-технологий.

Вот ключевые выводы, которые стоит унести с собой:

  1. Начинайте с данных: Четкая, валидируемая структура данных (YAML + Pydantic) — это 50% успеха. Она предотвращает баги и делает систему предсказуемой.
  2. Промпты — это тоже код: Выносите их в отдельные файлы. Структурируйте их с помощью ролевой игры, четких инструкций и примеров форматирования. Просите LLM возвращать данные в машинно-читаемом формате (HTML, JSON), чтобы избежать хрупкого парсинга текста.
  3. Не заставляйте LLM делать лишнюю работу: Используйте RAG-подход для извлечения информации из больших объемов текста. Сначала найдите релевантные фрагменты, потом задавайте вопросы. Это дешевле, быстрее и точнее.
  4. Проектируйте с прицелом на будущее: Используйте паттерны, такие как адаптер для LLM-провайдеров и фасад для упрощения API. Это позволит вашей системе легко эволюционировать.
  5. Используйте лучшие инструменты для задачи: Не пытайтесь написать PDF-рендер на Python, если можно заставить браузерный движок Blink сделать это за вас через CDP. Умный инженер не тот, кто пишет всё с нуля, а тот, кто умело комбинирует существующие мощные инструменты.

AIHawk — это прекрасный пример того, как должен выглядеть AI-проект второго поколения. Не просто "proof-of-concept", а надежная, расширяемая и продуманная система. Изучая такие архитектуры, мы учимся строить не просто интересные игрушки, а настоящие, работающие в реальном мире AI-агенты.