После прошлой статьи мне в личку прилетел вопрос: «А что на входе? Как именно ты подаёшь данные агенту? Просто кидаешь текст ТЗ — и всё?»

Отвечаю сразу вот он, паспорт требования, с которым работает мой агент:

json (паспорт требования)
json (паспорт требования)

Это пример паспорта для требования «Пользователь должен иметь возможность гибко настраивать отчёт». Да, в реальном пайплайне такое требование сначала разбивается на атомарные части (о чём расскажу в разделе 1), и каждая часть получает свой паспорт. Но для иллюстрации того, как выглядят признаки и что они значат, этот пример отлично подходит. Дальше в статье я покажу, как меняются паспорта после дробления, а пока просто запомните: агент работает не с текстом, а с такими структурами.

Теперь по порядку как мы к этому пришли.

1. Не ТЗ целиком, а отдельные требования

На старте эксперимента я тоже пыталась пойти простым путём: загрузить весь документ в нейросеть и попросить найти ошибки. Результат оказался предсказуемо печальным. Текст ТЗ это обычно несколько страниц, где перемешаны контекст, общие слова и конкретика. Если отдать его целиком, модель:

  • теряет фокус на отдельных утверждениях;

  • начинает смешивать требования из разных разделов;

  • не может дать чёткую обратную связь по каждому пункту.

Поэтому первый шаг  разбить ТЗ на атомарные требования. Что я считаю требованием? Это законченная мысль, описывающая одно действие, правило или ограничение. Обычно такие фразы начинаются с «Система должна…», «Пользователь может…», «При наступлении события…».

Пример из реального ТЗ:

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

Я разбиваю это на отдельные требования:

  1. Пользователь может выбирать период в отчёте.

  2. Пользователь может группировать отчёт по контрагентам.

  3. Пользователь может группировать отчёт по складам.

  4. Настройки отчёта сохраняются индивидуально для каждого пользователя.

Как можно ошибиться при дроблении:

  • Слишком мелко: разбить «сохранять настройки» на «сохранять период», «сохранять группировку» и т.д. это избыточно, потому что в реализации эти механизмы обычно общие. Агент начнёт плодить лишние замечания.

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

Зачем мы это делаем? Чтобы каждый маленький кусочек требований можно было проверить по отдельности, а потом собрать общую картину.

Наследование контекста

Чтобы не потерять связь с «родителем», в JSON-паспорт каждого требования я добавляю поля:

  • parent_section — название раздела ТЗ (например, «Отчёт по продажам»)

  • parent_object — объект, к которому относится требование (например, «Report.Sales»)

Я использую текстовые названия, потому что мне их удобно читать при отладке. В более сложной системе можно заменить на числовые идентификаторы суть не меняется.

Связанные требования (Linked)

Иногда одно требование содержит логическое «И» и неразрывно связывает несколько условий. Например: «Система должна отправлять уведомление на почту и в Telegram». Формально это два требования, но реализовывать их лучше вместе. Я помечаю такие пары как linked: true. Технически при параллельной обработке они отправляются в одном батче или помечаются флагом, а агент-критик потом проверяет целостность (например, чтобы не получилось, что одно сделали, а другое забыли).

2. Паспорт требования: от текста к числам

Дальше в дело вступает LLM-анализатор. Его задача превратить текст требования в структурированный JSON-паспорт т. е. набор числовых признаков. Почему не оставить текст? Потому что классификатору (дереву решений) нужны числа, а не слова. А LLM как раз умеет извлекать из слов нужные сигналы.

Важно: LLM здесь работает не как «чёрный ящик», а как feature extractor — она извлекает конкретные, заранее определённые признаки. Это не эмбеддинги, а интерпретируемые параметры: есть ли цифры, есть ли слова-маркеры и т.д. Такой подход даёт прозрачность и контроль.

Важное уточнение: когда я говорю, что LLM не чёрный ящик, я немного хитрю :) На самом деле она такой же чёрный ящик, как и любая нейросеть. Я не знаю, почему она сегодня считает фразу «при превышении лимита» граничным условием, а завтра нет. Это как спросить у кота, почему он сел именно на этот коврик, объяснения не будет.

Но: я хотя бы жёстко контролирую, что она должна выдать на выходе (JSON со строгими полями), и могу перепроверить её регулярками. Прозрачность здесь не в том, что я вижу её мысли, а в том, что я чётко ограничила ей рамки.

Какие признаки я использую?

В ходе экспериментов я перебрала около 15 потенциальных признаков, но остановилась на этих шести. Они давали наилучшее качество при минимальной сложности:

Признак

Что измеряет

Диапазон

has_numbers

Есть ли в требовании конкретные числа, даты, лимиты

0/1

stopword_score

Вес слов-маркеров размытости (гибкий, удобный, быстрый, интуитивный, качественный)

0–1

has_negative_keywords

Упомянуты ли исключения (иначе, если не, в случае ошибки)

0/1

boundary_conditions_mentioned

Есть ли граничные условия (пустые значения, максимумы, минимумы, отсутствие данных)

0/1

has_deadline

Указаны ли сроки или временные ограничения

0/1

actor_count

Сколько действующих лиц упомянуто явно (пользователь, администратор). Система по умолчанию не учитывается, так как она всегда подразумевается

число

Признаки подбирались не случайно. Я исходила из критериев хорошего ТЗ (бизнес-цель, полнота сценариев, однозначность, проверяимость), которые описала в первой статье. А потом, обучив дерево решений, посмотрела на важность признаков  они действительно оказались ключевыми.

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

Как LLM извлекает признаки: промпт, JSON mode, few-shot

Чтобы LLM работала стабильно и не галлюцинировала, я использую:

  • JSON mode — модель обязана выдавать строго структурированный JSON.

  • Few-shot examples — в промпт подкладываю 3–5 эталонных примеров с правильными паспортами. Это резко снижает ошибки.

  • Чёткая шкала для stopword_score: 0 — нет размытых слов; 0.3 одно такое слово; 0.6 два; 0.9 три и более. Так дерево получает стабильные числа, а не «шум» от настроения модели.

    Почему именно 0, 0.3, 0.6, 0.9? Честно, взяла с потолка. Дерево решений умнее меня: если передавать ему не дискретные баллы, а, скажем, долю стоп-слов в тексте, оно само найдёт лучший порог. В следующей версии так и сделаю.

    Пример неудачного извлечения: Однажды LLM не распознала граничное условие в требовании «При превышении лимита в 1000 строк выдавать предупреждение», она посчитала, что boundary_conditions_mentioned=0, потому что там нет слов «пусто», «ноль», «максимум». Пришлось дополнить инструкцию примерами с «превышение лимита». После этого качество выросло.

Как ещё я борюсь с галлюцинациями: Для has_numbers иногда подключаю регулярные выражения, если LLM пропустила цифры, но они есть в тексте (например, «2 секунды»), я исправляю признак постобработкой. Это «костыль», но он повышает надёжность.

Зачем я это рассказываю? Чтобы вы понимали: промпт это живая материя, его надо докручивать под свои данные.

Упрощённый промпт сейчас выглядит так:

промт
промт

3. Примеры превращения текста в паспорт

Возьмём типичную размытую формулировку, которая часто встречается в ТЗ:

«Пользователь должен иметь возможность гибко настраивать отчет»

LLM-анализатор выдаст (как в самом начале статьи):

json
json

Высокий stopword_score при нулевых значениях has_numbershas_negative_keywordsboundary_conditions_mentioned и has_deadline — классический портрет непроверяемого требованияactor_count=1 здесь не играет роли (он просто фиксирует наличие пользователя), главные маркеры размытости на месте, а конкретики и граничных условий нет.

Ещё раз: это не атомарное требование из нашего примера дробления, а отдельный случай, так часто пишут в реальных ТЗ. В составном требовании из раздела 1 мы бы разбили его на части, и каждая часть получила бы свой паспорт.

Например, атомарное требование «Пользователь может выбирать период в отчёте» выглядело бы так:

json
json

Здесь stopword_score=0, потому что слово «гибко» исчезло, и требование стало конкретным (даже без цифр оно проверяемо — достаточно убедиться, что выбор периода работает). Дерево решений, скорее всего, классифицирует его как ok (если не считать отсутствие граничных условий).

А теперь хорошее требование с негативным сценарием:

«При попытке провести документ без заполненного поля "Контрагент" система должна выдавать сообщение: "Укажите контрагента"»

json
json

Здесь есть и негативный сценарий (has_negative_keywords=1), и граничное условие (поле не заполнено). Даже без чисел требование качественное.

Ещё пример с числами:

«Время формирования отчёта не должно превышать 2 секунд при объёме данных до 10 000 записей»

json
json

Числа есть, граничное условие (до 10 000) есть. actor_count=0, потому что в требовании нет явно упомянутых людей — только система (она по умолчанию не учитывается). Всё по правилу.

4. Откуда берутся метки (таргет)

Чтобы обучить классификатор, нужен размеченный датасет. Я взяла 90 ТЗ (30 отличных, 30 средних, 30 проблемных), разбила их на 270 отдельных требований и каждому вручную присвоила метку тип ошибки или ok.

Методология разметки: Я размечала все примеры сама, опираясь на свой чек-лист из первой статьи. Чтобы снизить субъективность, я перепроверяла спорные случаи через месяц и если мнение менялось, пересматривала метку. Коллег не привлекала, потому что это был личный эксперимент, но для продукта стоит использовать несколько экспертов и считать согласованность.

Метки:

  • ok — требование хорошее, ошибок нет.

  • unverifiable — непроверяемое (размытые формулировки, нет критериев).

  • no_negative — описаны только позитивные сценарии, нет исключений.

  • no_boundary — отсутствуют граничные условия.

  • ambiguity — неоднозначная формулировка (можно трактовать по-разному).

Распределение получилось примерно таким:

  • ok — 80

  • unverifiable — 60

  • no_negative — 50

  • no_boundary — 40

  • ambiguity — 40

Классы слегка несбалансированные (больше ok), но для дерева решений я использовала взвешивание классов (параметр class_weight='balanced'), чтобы модель не игнорировала редкие ошибки.

Пограничный случай: Например, требование «Система должна автоматически сохранять данные каждые 5 минут». С одной стороны, есть число (5 минут), с другой нет негативных сценариев (что делать, если сохранение не удалось?). Я отнесла его к no_negative, потому что отсутствие обработки ошибок критичнее.

5. Репрезентативность выборки: почему без неё всё летит в помойку

Главный критерий успеха любых данных — это репрезентативная выборка. Что это значит? Объясняю на пальцах.

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

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

С ТЗ то же самое. Если я соберу документы только из торговли и только от себя любимой — это «десерты и коктейли». А чтобы агент научился работать с любыми ТЗ, в выборке должны быть:

  • Разные отрасли: торговля, производство, услуги, логистика. В каждой — свои типичные ошибки.

  • Разные авторы: я, мои коллеги, заказчики, внешние аналитики. У каждого свой стиль и слепые зоны.

  • Разный объём: от мелких доработок (2–3 страницы) до больших внедрений (50+ страниц). В маленьких ТЗ часто нет контекста, в больших — противоречия между разделами.

  • Разные годы: старые проекты (5–7 лет назад) и свежие. Требования к ТЗ меняются, заказчики умнеют, разработчики наглеют :) Нужно, чтобы агент понимал и «олдскульные» формулировки, и современные.

Важно: моя выборка репрезентативна именно для моего домена торговля, производство, услуги. Как только я столкнулась с проектом из HoReCa (ресторанный бизнес), агент споткнулся: там своя терминология и типовые ошибки. Пришлось дообучать на новых примерах. Имейте это в виду, если будете повторять эксперимент: при смене предметной области данные придётся расширять.

Я перепроверяла свою выборку по этим пунктам трижды. Репрезентативность это не про количество, а про разнообразие. Лучше 30 ТЗ, закрывающих все грани задачи, чем 300 однотипных.

6. Зачем классификатор, а не просто правила if-else?

Этот вопрос мне задали в комментариях, и он очень правильный. Действительно, можно было бы написать что-то вроде:

if has_numbers == 0 and stopword_score > 0.7: return "unverifiable" if has_numbers == 1 and boundary_conditions_mentioned == 0: return "no_boundary"

Но в моём случае, с ростом числа примеров и признаков, поддерживать такие правила вручную стало сложно. Почему:

Представьте, что вы описываете рецепт словами «добавить щепотку соли». Что значит «щепотка»? Для дерева решений это не проблема: оно посмотрит на тысячи примеров и само поймёт, что щепотка — это примерно 2–3 грамма, а если солить в первый раз лучше недосолить.

С нашими признаками так же. Вручную писать правила «если есть числа, но нет граничных условий → ошибка» можно, но когда признаков станет больше, эта простыня из if-else будет весить сотни строк. А дерево само разложит всё по полочкам

  • Нелинейность связей. Комбинации признаков сложнее, чем кажутся. Например, требование с has_numbers=1 может быть ok, если есть ещё и негативные сценарии, а может быть no_negative, если негативных сценариев нет. Дерево решений автоматически находит такие нелинейные зависимости.

  • Автоматический подбор порогов. Для непрерывного признака stopword_score нужно где-то взять порог (0.7?). Дерево само подбирает оптимальный порог, минимизируя энтропию, а не на глазок.

  • Избежание «лапши» из условий. Правила вручную привели бы к сотням строк кода, которые сложно поддерживать. А дерево компактно и легко интерпретируется.

  • Лёгкость обновления. Если я нахожу новый тип ошибки или хочу уточнить правила, мне достаточно добавить примеры в датасет и переобучить модель заново (деревья решений не поддерживают частичное дообучение, только полное переобучение на расширенной выборке).

Признание: для очень маленького датасета (скажем, 20–30 примеров) правила могут быть не хуже и даже проще. Если у вас мало данных начните с правил, они прозрачнее. А когда данных станет много переходите на дерево.

Метрики качества: На тестовой выборке (20% данных) accuracy держится около 82%. Но важнее смотреть на recall по редким классам:

  • для ambiguity — около 70% (иногда неоднозначность похожа на ok);

  • для no_negative — 75%;

  • для no_boundary — 80% (после добавления этого признака recall вырос с 60%).

Для тех, кто помнит мою первую статью: там я отказывалась от accuracy, потому что оценивала качество текстовых отчётов агента. Здесь же мы оцениваем работу классификатора — это строгая задача классификации, и для неё accuracy и recall вполне подходят. Так что никакой шизофрении, просто разные объекты измерения.

Такой уровень меня устраивает для прототипа.

7. Арбитраж: агент-критик как судья

Классификатор работает на уровне отдельных требований (микроанализ). Он выдаёт «сырые» метки: «это требование непроверяемое», «это ok». Но есть вещи, которые на этом уровне не видны:

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

  • Пропущенные права доступа (кто может видеть кнопку?).

  • Маппинг данных для интеграций.

    Здесь в игру вступает агент-критик  та же локальная LLM (запущенная через Ollama), но с другим промптом, которая получает:

  • весь исходный текст ТЗ;

  • список требований с их JSON-паспортами и предсказанными метками;

  • контекстные связи (родительские разделы, связанные требования).

Зачем критику паспорта, если он читает текст? Хороший вопрос. Паспорта для него как шпаргалка. Он сразу видит, где классификатор уже нашёл проблемы, и может специально в этих местах покопаться.

Но противоречия между требованиями он ловит только читая текст. Если в одном месте сказано «склад обязателен», а в другом «можно без склада», ни один паспорт это не покажет. Тут без живого чтения не обойтись.

Как критик использует паспорта: Он может видеть, что требование помечено как ok, но при этом в соседнем требовании есть противоречие. Тогда он перепроверяет исходный текст и выдаёт замечание.

Конкретный пример: В одном ТЗ было требование «Поле "Склад" обязательно для заполнения», а в другом — «При импорте данных поле "Склад" может быть пустым, тогда склад определяется по умолчанию». Классификатор по отдельности каждое требование счёл ok (в каждом есть логика). Но критик увидел противоречие и написал в отчёте: «Внимание: в разделе 2.1 склад обязателен, а в разделе 3.4 допускается пустое значение с подстановкой по умолчанию. Уточните, какое поведение верное».

8. Скорость и масштаб: параллельная обработка

Если в ТЗ 50–100 требований, последовательно прогонять каждое через LLM-анализатор долго (секунды на требование — и уже минуты). Но поскольку требования атомарны и независимы (с учётом наследования контекста), их можно обрабатывать параллельно. Я использую ThreadPoolExecutor (в Python) и отправляю запросы к локальной Ollama одновременно.

Почему Ollama? Она позволяет запускать модели (например, Mistral или Llama 3 8B) локально, бесплатно и без доступа в интернет — идеально для экспериментов и прототипов. Никаких затрат на API, никаких рисков утечки данных.

Важное примечание для тех, у кого игровая карта:
Кстати, если у вас игровая видеокарта (спасибо мужу-девопсу, который собрал правильную машину!) вы уже вооружены для экспериментов. Моя, например, спокойно переваривает 4–6 параллельных запросов к Ollama, и я даже не замечала проблем, пока не начала замерять скорость. Так что если у вас есть игровой ПК — считайте, что у вас уже есть готовая лаборатория для локальных моделей. И никаких дополнительных вложений!

Технические детали:

  • Размер пула — 4–6 потоков, чтобы не перегружать видеокарту.

  • Для связанных требований (linked) они отправляются в одном потоке, чтобы сохранить порядок.

Проблемы: Иногда модель не справляется с параллельными запросами и начинает тормозить — приходится уменьшать число потоков. Но в целом ускорение в 3–4 раза на 100 требованиях достижимо.

Зачем это нужно? Чтобы анализ ТЗ не превращался в чаепитие, а занимал разумное время.

9. Итог: что даёт такая структура данных

  • Прозрачность. Мы точно знаем, на каком основании агент ругается на требование: LLM извлекла признаки, дерево их скомбинировало.

  • Контроль. Можно добавить новый признак (например, has_ui_mockups) и переобучить модель, не ломая код.

  • Экономию. LLM используется только для извлечения признаков (с few-shot и JSON mode это недорого) и для макроанализа. Основная масса классификации делается деревом.

  • Масштабируемость. Новые примеры просто добавляются в датасет, и модель становится умнее без переписывания правил.

  • Полноту. Атомарные требования не теряют контекст благодаря наследованию, а связанные требования проверяются в паре.

Честное ограничение подхода

Чтобы вы не думали, что я продаю вам идеальный продукт, — вот список проблем, с которыми я столкнулась:

  1. Дерево ошибается в 15% случаев. Особенно на требованиях, где смешаны несколько типов ошибок (например, одновременно размытость и отсутствие граничных условий). На тестовой выборке accuracy держится в районе 80–85%, и это потолок для моего датасета.

  2. LLM иногда галлюцинирует признаки. Как в примере с «превышением лимита» — пришлось докручивать few-shot и добавлять постобработку.

  3. Субъективность разметки. 270 требований размечала я одна. Если бы это делали три разных аналитика, метки могли бы разойтись в 10–15% случаев. Для моего личного инструмента это ок, для продукта — потребовалась бы валидация.

  4. Подход не работает для ультракоротких ТЗ. Если в документе 1 страница и 5 размытых фраз — дробление не даст статистики, а дереву не на чем учиться. Тут уже нужны другие методы (или просто идти пить кофе с автором, обсуждая, как он умудрился написать ТЗ на салфетке).

Главный вывод: весь этот пайплайн сплошной компромисс. Это как выбрать между готовым тортом из магазина (не знаешь, что внутри, но быстро) и домашним пирогом по бабушкиному рецепту (долго, муторно, но каждый ингредиент знаком).

Для прототипа — ок. Для продакшена — надо думать.

Что я сделала бы иначе сейчас

Оглядываясь назад, я понимаю, сколько шишек я набила.. Вот мои главные выводы — возможно, они помогут вам не повторять моих ошибок.

1. Сразу ввела бы больше признаков. Например, has_mockuphas_business_goal. И собирала бы статистику по ним с первого дня. Это избавило бы от необходимости переразмечать часть данных позже.

2. Автоматизировала бы проверку согласованности разметки. Сейчас я перепроверяла спорные случаи вручную через месяц. Лучше было бы попросить коллегу переразметить 10% примеров и посчитать согласованность — это показало бы реальную субъективность моей схемы.

3. Настроила бы мониторинг качества в реальном времени. Чтобы каждый новый прогон агента сразу показывал, на каких требованиях он ошибается, а не копить ошибки до итерации.

4. Не надеялась бы, что теорию легко применить на практике. Я знала про выборки, про деревья решений, про важность признаков в общих чертах. Но когда дело дошло до реальной работы, оказалось, что знать и уметь, это две большие разницы. Я аналитик, а не программист и не дата-сайентист. Пришлось разбираться, как эти деревья решений реально работают в коде, почему они выдают именно такие split-ы и что делать, когда accuracy не растёт. Документация на английском, а с ним у меня так себе (разговорный есть, а технические тексты — боль).

Первые 4 месяца разметки 90 ТЗ дали результат, близкий к нулю агент тупил, ошибался и придумывал несуществующие проблемы. Пару раз я была готова плюнуть и забыть. Удержала только злость на потраченное время: «Я столько вбухала, теперь уже нельзя отступать».

И всё-таки, знаете... Самое трудное было когда я НЕ ЗНАЛА, как подступиться. Казалось, что машинное обучение это какая-то магия для избранных, а все вокруг уже давно всё понимают. Это чувство «я не в своей тарелке» выматывало сильнее, чем любые технические сложности.

А теперь, когда я прошла этот путь, магия рассеялась. Я вижу, сколько ещё нужно сделать: докрутить признаки, расширить датасет, перепроверить разметку, улучшить промпты. До идеального продукта как до луны пешком. Но самое главное  страх неизвестности ушёл.

Я понимаю, ЧТО именно нужно улучшать и КАК это делать. И это чувство стоит всех потраченных нервов.

Так что мой главный совет: не бойтесь начинать. Трудно только до тех пор, пока не поймёшь. А когда поймёшь, вас уже не остановишь.

Если решите повторить, не жалейте времени на сбор данных это основа всего. И обязательно делитесь опытом!


А какие признаки важны в ваших ТЗ? Какие формулировки чаще всего вызывают споры? Делитесь в комментариях — соберём банк идей !