Здравствуйте, коллеги!

Три недели назад я взялся за задачу, которая звучит просто: сделать бота‑ассистента, который помогает IT‑джуну собрать резюме, и при этом не выдумывает того, что соискатель не описывал. И задача оказалась не про prompts. Ниже — архитектура, которая получилась и 3 инженерные истории, на которых я застревал по‑настоящему.

Всё, что описано, код и цифры — проверялось на живом MVP.

Вопрос: «Что мешает джунам сразу напрямую в ChatGPT собрать себе нормальное резюме?»;

Утверждения: «У джунов сейчас другие реальные проблемы — бесполезные платные курсы, на которых обещают золотые горы» и «Джуны и вчерашние вкатуны не нужны, никому»

Подобное оставляем за скобками данной статьи.

Почему одним prompt’ом не получается

Поначалу идея реализации может казаться очевидной: взять знакомую нейросеть, скормить ей резюме и попросить «сделай мне красиво». Но проблема в том, что она охотно привирает, пытаясь приукрасить, дописать навыки, метрики, стек проектов, которых у джуниор разработчика по факту нет.

Просить «не выдумывай» в prompt’е — не надежная гарантия. Значит контроль фактов надо закладывать на этапе архитектуры. Отсюда вытекает сквозной принцип GROUNDING: система может переформулировать, структурировать и усилить, но не выдумать факт, которого не было во входных данных.

Архитектура: конвейер из агентов

Разбил задачу на конвейер из 6 специализированных агентов, каждый с узкой ответственностью и строгим контрактом input‑output:

  • A1 Parser — извлекает структурированный Profile из текста резюме (только факты).

  • A2 Track — классифицирует карьерный трек (Industry / Research / Education / Startup).

  • A3 Matcher — проводит gap‑анализ навыков под целевую роль have/partial/missing (если указана, если нет — она выясняется в диалоге).

  • A4 Turn — ведёт диалог, дозаполняет Profile, по вопросу за ход.

  • A5 Rewriter — переписывает резюме под структуру hh.ru

  • A6 Critic — проверяет фактологичность результатов.

Всё крутится над каноническим Profile (Json, валидация pydantic). Каждый агент возвращает структурированный ответ по схеме — это отлавливает львиную долю проблем на этапе валидации, а не в runtime.

Что даёт разбиение на узкие роли (кроме чистоты и гигиены):

  • тестируемость — можно измерять качество каждого шага отдельно (accuracy классификации трека, например).

  • дешёвая маршрутизация — разным агентам можно давать разные модели.

  • локализация ошибок — можно отловить, какой именно агент сломался, если что.

Клиент: force‑JSON, repair, fallback по тирам

Провайдер — агностический клиент, поверх OpenAI‑совместимого API. Три вещи нужно закладывать сразу:

  • Force‑JSON. response_format={"type": "json_object"} резко снижает количество мусора на выходе.

  • Repair‑проход. Если модель уже вернула невалидный JSON или не попала в схему — даём ей шанс исправиться, скормив собственную ошибку:

        try:
            resp = client.chat.completions.create(
                model=model,
                messages=[
                    {"role": "system", "content": system},
                    {"role": "user", "content": user},
                ],
                temperature=temperature,
                response_format={"type": "json_object"},
                extra_headers=extra_headers if provider == "provider_c" else None,
            )
            if not resp.choices or resp.choices[0].message.content is None:
                raise ValueError(f"\nNo content in response from {model}\n")

            raw = resp.choices[0].message.content or "{}"
            data = json.loads(raw)
            _log_usage(agent, model, resp)
            return schema.model_validate(data)

        except RateLimitError as e:
            logger.error("\n[%s] Rate limit exceeded for %s: %s\n", agent, model, e)
            raise e

        except APIStatusError as e:
            logger.warning("\n[%s] API error %s for %s: %s\n", agent, e.status_code, model, e.message)
            last_error = e
            continue

        except (json.JSONDecodeError, ValidationError) as e:
            logger.info("\n[%s] Parsing error for %s, attempting repair: %s\n", agent, model, e)
            last_error = e

            try:
                repair_user = (
                    f"Предыдущий ответ был невалидным JSON или не соответствовал схеме. "
                    f"Исправь и верни ТОЛЬКО валидный JSON.\n"
                    f"Ошибка: {e}\n"
                    f"Текст: {raw}"
                )
                resp = client.chat.completions.create(
                    model=model,
                    messages=[
                        {"role": "system", "content": system},
                        {"role": "user", "content": repair_user},
                    ],
                    temperature=temperature,
                    response_format={"type": "json_object"},
                    extra_headers=extra_headers if provider == "provider_c" else None,
                )
                if not resp.choices or resp.choices[0].message.content is None:
                    raise ValueError(f"\nRepair - No content in response from {model}\n") from None

                raw = resp.choices[0].message.content or "{}"
                data = json.loads(raw)
                _log_usage(agent, model, resp)
                return schema.model_validate(data)
  • Fallback по тирам. Модели описаны списками, агент привязан к тиру. Если модель на провайдере недоступна — идем к следующей, не роняя pipeline:

# часть config.py

MODEL_TIERS = {
    "light": [
        {"provider": "provider_a", "model": "model_1"},
        {"provider": "provider_b", "model": "model_2"},
    ],
    "heavy": [
        {"provider": "provider_a", "model": "model_1"},
        {"provider": "provider_b", "model": "model_2"},
        {"provider": "provider_c", "model": "model_3"},
    ],
}

AGENT_TIER = {
    "track": "light",
    "parser": "heavy",
    "matcher": "heavy",
    "turn": "heavy",
    "rewriter": "heavy",
    "critic": "heavy",
}

Такая конструкция стоит недорого, а окупается сразу: если провайдер возвращает ошибки 401/404/429 — легко увидеть в логах. Дополнительный эффект — можно проводить эксперимент разных агентов на разных моделях без правки кода этих агентов.

Я попробовал разные варианты: в итоге на всех ролях осталась одна модель — она устроила по качеству и цене. 

Агент критик == LLM‑as‑a-judge

Ключевое решение против галлюцинаций. Перечитывает сгенерированное A5 Rewriter резюме и сверяет каждое утверждение с исходными фактами из Profile и истории диалога. Возвращает структуру с grounding_ok с подозрительными утверждениями и рекомендациями по исправлению.

# часть schemas.py

class Critique(BaseModel):
    grounding_ok: bool
    fabricated_claims: list[str]
    completeness: float = Field(ge=0, le=1)
    format_ok: bool
    fixes: list[str]

Тут три неочевидных момента:

  • Критику A6 нужен полный контекст, иначе он врёт в обратную сторону.

1я версия получала на вход только Profile и итоговое резюме. В результате критик исправно помечал как «выдумку» факты, которые пользователь сообщал в диалоге, но которые не успели попасть в Profile. Ложные срабатывания порождали лишние перегенерации. Поэтому правильный input критика должен включать всё, что видел генератор: Profile + история диалога + сгенерированный текст. Банально, но на этом можно обжечься.

  • Логика цикла работы A6 живёт в коде, а не в prompt’е.

Если A6 поднял флаг (обнаружил противоречия и что‑то, чего пользователь не говорил) — запускается ровно 1 повторный проход A5 с замечаниями. Никаких бесконечных петель/непредсказуемого числа проходов и зря сожженных токенов: максимум 2 вызова на A5 Rewriter . На честных прогонах A6 стабильно подтверждает grounding — Rewriter не раздувает даже скромные ответы.

resume_output = rewrite_resume(
                    profile=profile,
                    track=track,
                    gap=gap,
                    history=st.session_state.messages
                )

                # Раунд 2: A6 (Critic)
                with st.spinner("Проверяем на соответствие фактам..."):
                    critique = critique_resume(
                        profile=profile,
                        history=st.session_state.messages,
                        content_markdown=resume_output.content_markdown
                    )
                    st.session_state.critique = critique.model_dump()

                # Раунд 3: Повторный A5, если есть галлюцинации
                if not critique.grounding_ok:
                    with st.spinner("Исправляем замечания критика..."):
                        resume_output = rewrite_resume(
                            profile=profile,
                            track=track,
                            gap=gap,
                            history=st.session_state.messages,
                            fixes=critique.fixes
                        )
  • Критик не волшебная палочка, а нижняя граница.

A6 как вероятностная модель тоже ошибается. Он не даёт гарантии «фактов нет», а повышает цену попадания выдумки в результирующее резюме. Гарантий в этой архитектуре не бывает. Есть только снижение вероятности и его можно и надо измерять (об этом в блоке про eval).

Почему слияние данных — не работа LLM

Merge нового ответа пользователя со старым Profile делает детерминированный код, не модель. Потому что здесь цена ошибки высока: легко затереть уже сохраненные навыки/факты или потерять часть bullets проекта при обновлении Profile. Поэтому логика сляния живёт в core/merge.py и покрыта unit‑тестами. Модель предлагает патч — код его аккуратно принимает.

def test_add_skill_no_overwrite():
    """Новый навык добавляется, не затирая прежние."""

    profile = {"skills": ["Python"]}
    patch = {"skills": ["Docker"]}
    result = merge_profile(profile, patch)
    assert "Python" in result["skills"]
    assert "Docker" in result["skills"]

def test_update_project_keeps_bullets():
    """Обновление проекта сохраняет полный список буллетов, не дублируя элемент."""

    profile = {
        "projects": [
            {"name": "App", "bullets": ["b1", "b2"]}
        ]
    }
    patch = {
        "projects": [
            {"name": "App", "bullets": ["b1", "b2", "b3"]}
        ]
    }
    result = merge_profile(profile, patch)
    assert len(result["projects"]) == 1
    assert len(result["projects"][0]["bullets"]) == 3

def test_contacts_preserved():
    """Добавление ссылки в contacts не затирает email и телефон."""

    profile = {"contacts": {"email": "a@b.ru", "phone": "123"}}
    patch = {"contacts": {"email": "a@b.ru", "phone": "123", "github": "url"}}
    result = merge_profile(profile, patch)
    assert result["contacts"]["email"] == "a@b.ru"
    assert result["contacts"]["github"] == "url"

def test_empty_patch():
    """Пустой патч не изменяет профиль."""

    profile = {"full_name": "Ivan"}
    result = merge_profile(profile, {})
    assert result == profile

Три истории, где я реально застревал. Именно они, а не prompt’ы, съели немало времени.

Грабли № 1: кириллица в PDF и библиотека, которая криво поднималась.

Первую версию PDF‑рендера собрал на weasyprint, хотел сразу красивый HTML/CSS, но при деплое на Streamlit Cloud возвращала OSError: cannot load library libgobject-2.0.0. weasyprint тянет системные C‑библиотеки (pango, cairo, glib) и даже добавление их в отдельный packages.txt не принесло результатов. 

Поэтому не стал долго бороться с окружением и переехал на fpdf2 — чистый python без системных зависимостей. Шрифты кириллицы скачал и подключил через DejaVuSans.ttf. 

font_path = "fonts/DejaVuSans.ttf"
  if not os.path.exists(font_path):
    font_path = os.path.join(os.path.dirname(__file__), "..", "fonts", "DejaVuSans.ttf")

    pdf.add_font("DejaVu", "", font_path)
    pdf.set_font("DejaVu", size=12)

После успешного рендера резюме с кириллицей поймал баг с «поехавшими шрифтами» — он оказался в дефолтных настройках последней версии fpdf2 (2.8.7) — курсор по умолчанию уезжал вправо. Пофиксил строкой с явным возвращением курсора.

Было:

pdf.multi_cell(0, 8, line)   # курсор уезжает вправо, ширина схлопывается

Стало:

from fpdf.enums import XPos, YPos
...

if line.startswith("###"):
  pdf.set_font("DejaVu", size=14)
  pdf.multi_cell(pdf.epw, 10, _clean(line.lstrip("# ").strip()),
                  new_x=XPos.LMARGIN, new_y=YPos.NEXT)
  pdf.set_font("DejaVu", size=12)

Отлаживать корректность PDF, каждый раз проходя диалог с LLM, дорого по времени и токенам. Проще завести скрипт для каждого проблемного модуля. Ноль вызовов API, мгновенная интеграция. 

...

SAMPLE_OUT = """
# Желаемая должность
Python-разработчик

 О себе
Python-разработчик с 2 годами практики и опытом реализации пет-проектов на Django и Flask.

 Ключевые навыки
* Django
* Flask
* PostgreSQL

## Опыт работы
*

 Проекты
**Backend интернет-магазина на Django**
* Разработал каталог товаров, корзину и оформление заказов.
* Реализовал обработку платежей через API эквайринга на Python.

**Telegram-бот для отслеживания цен на Flask**
* Парсинг цен через BeautifulSoup и Selenium.
* Уведомления пользователю при снижении цены.
"""


def main():
    pdf_bytes = generate_pdf(SAMPLE_OUT)
    with open("test_resume.pdf", "wb") as f:
        f.write(pdf_bytes)
    print("PDF создан: test_resume.pdf")

if __name__ == "__main__":
    main()

Мелочи рендера, всплывающие только на реальных данных

a) Markdown‑разделители попадают в bullets. Генератор выдаёт --- между блоками, наивный парсер видит строку, начинающуюся с -, и печатает • --. Фикс — пропускать строки, состоящие только из символов разметки:

# пропускаем markdown-разделители (---, ***, ___)
if set(line) <= {"-", "*", "_"} and len(line) >= 2:
  pdf.ln(3)
  continue

Наивная чистка markdown ломает данные. Убрать * можно простым replace. А вот убрать тем же способом _ значит покалечить ivan_petrov@mail.ru и github.com/some_user. Нужна регулярка, снимающая только парную разметку:

def _clean(text: str) -> str:
    """Убирает markdown-звёздочки (**bold**, *italic*, _underscore_) — fpdf2 их не парсит."""

    text = text.replace("**", "").replace("*", "")
    text = re.sub(r'(?<!\w)_(.+?)_(?!\w)', r'\1', text)
    return text

b) Пустые блоки (опыт работы, достижения) печатаются заголовками.

Модель недетерминированно то опускает пустую секцию, то печатает её заголовок, то дописывает «не указано». Корректировать prompt'ом можно бесконечно. Надёжнее выкинуть заголовок в коде, заглянув вперёд:

# ── Постобработка: убрать пустые блоки (заголовок без содержимого) ──
    block_titles = {"Опыт работы", "Проекты", "Образование", "Контакты",
                    "Ключевые навыки", "Знание языков", "Достижения", "О себе"}
  
    raw_lines = [ln.strip() for ln in markdown_text.split("\n")]
    lines = []
    for i, ln in enumerate(raw_lines):
        # если строка — название блока, проверяем, есть ли под ним содержимое
        clean_ln = ln.lstrip("# ").strip().rstrip(":")
        if clean_ln in block_titles:
            # ищем следующую непустую строку
            nxt = next((raw_lines[j].lstrip("# ").strip().rstrip(":")
                        for j in range(i + 1, len(raw_lines)) if raw_lines[j].strip()), "")
            
            if nxt in block_titles or nxt == "":
                continue
        lines.append(ln)

Грабли № 2: ссылки, которых «нет» в исходном резюме.

В некоторых резюме бывает, что ссылки на контакты типа GitHub/Telegram/Linkedin сделаны гиперссылками-якорями: видимый текст — только слово «GitHub», а его URL спрятан в аннотации PDF (слой /Annots). А используемый мной pdfplumber хватает только видимое слово при парсинге. И тут важно понимать, что правкой prompt’а A1 затык не решить — LLM не извлечёт того, чего нет во входных данных. 

Настоящий фикс (в будущем) — доставать URL из аннотаций (pypdf) и приклеивать к тексту. Но это отдельная таска с тестами, поэтому её отправил в backlog, а для MVP сделал обходной путь: A4 Turn в начале диалога распознаёт подобные заглушки‑якоря и переспрашивает у пользователя корректные ссылки. Они попадают в Profile и в финальном PDF становятся кликабельными (на больших резюме может не сработать с 1го раза, опять же из‑за не детерминизма LLM).

Грабля № 3: Streamlit и исчезающий экран.

Под конец добавил кнопку «Новое резюме» с логикой: очистить session_state, перезапустить. И начал получать пустой экран.

Самое интересное — почти идентичная кнопка сброса диалога рядом работала. Разница обнаружилась не сразу. После del скрипт продолжал выполняться до конца функции и обращается к ключам, которых уже нет. Починил сбросом состояния только через модалку/изолированный контекст, не inline.

@st.dialog("Вернуться на главную")
def return_home_dialog():
    st.write("Возвращаемся на стартовую страницу, ваш диалог сбросится.")
    col1, col2 = st.columns(2)
    with col1:
        if st.button("Отмена", type="secondary", use_container_width=True):
            st.rerun()
    with col2:
        if st.button("Да, возвращаемся", type="primary", use_container_width=True):
            for key in list(st.session_state.keys()):
                del st.session_state[key]
            st.rerun()


...

      with col_new:
        if st.button("🆕 Новое резюме", type="secondary", use_container_width=True):
          return_home_dialog()

Измеримое качество: eval и честный error‑analysis

Для этого собрал небольшой набор реальных резюме, размеченных вручную по трекам (Industry / Research / Education / Startup) и скрипт, который прогоняет классификацию и считает метрики accuracy определения трека, стабильности, grounding‑rate, latency.

1й прогон дал accuracy трека 80% при 100% стабильности ответов. Разбор ошибок показал, что они лежат на границе треков Research / Education, и посмотрев датасет внимательнее, я понял, что в этих случаях модель была права, а не моя ручная разметка. После уточнения разметки и усиления prompt’a A2 accuracy поднялась до 90–100%. Grounding‑rate выдумок не выявила.

Замер latency:

A1 (parser) ~12 с, A2 (track) ~2.5 с, A5 (rewriter) ~9 с, A6 (critic) ~2.6 с.

Детерминированное ядро (merge ответов со старым Profile и PDF I/O) я покрыл unit‑тестами через pytest и повесил на GitHub Actions прогоном на каждый push. Агентов не покрывал unit‑тестами сознательно — они не детерминированы и требуют вызовов API. 

Выводы, при разработке похожих приложений

  • Разбивка задачи на агентов с узкими контрактами: это тестируем и управляемо.

  • Контроль фактов встраивать в архитектуру: LLM‑as‑a-judge + логика цикла в коде, а не надежда на один длинный prompt.

  • Детерминированное — в код, не детерминированное — в модель: слияние состояний слишком важно, чтобы делегировать его LLM.

  • Тест‑скрипты изолированы: для проблемных модулей отслеживание состава PDF или работы парсера без запуска всего pipeline дёшево и быстро.

  • Данные важнее prompt’а: если модель не видит факты, сначала проверьте, доходят ли они до неё вообще.


Повторю ещё раз, что это MVP и есть, что улучшать. Поэтому буду рад вопросам и критике в комментариях, особенно по архитектуре.

Ссылка на репо https://github.com/AlekseyYudin-161/JunMate