2000 тест-кейсов и три AI-агента: как я разбирал тестовую базу
Представьте: вы пришли на проект, а там около двух тысяч тест-кейсов. Часть проверок повторяется, часть написана несколько лет назад. Где-то нет предусловия, где-то ожидаемый результат нужно угадывать по шагам. При этом задачи уже в работе, релиз приближается, и времени спокойно перечитать всё нет.
Удалять старые кейсы на глаз нельзя. Переписывать каждый вручную — долго. Оставить как есть — значит снова идти в регресс с базой, которой не вполне доверяешь.
Так появилась задача для моих агентов: помочь разобрать накопленные проверки и подготовить предложения, с которыми уже можно работать. Какие кейсы похожи? Что требует уточнения? Где есть описанная функциональность, но нет проверки?
Я QA-инженер. Прикладной код писал и дорабатывал вместе с AI. С моей стороны были постановка задачи, участие в разработке, проверка результата, поиск ошибок и приёмка изменений. Нейросеть помогала с реализацией, но определить, соответствует ли полученный кейс реальному требованию, всё равно приходилось мне.
Название компании и реальные тест-кейсы в статье не публикуются. Учебный пример ниже создан отдельно. А проблемы с документацией, предусловиями и одобрением неверных кейсов — из опыта разработки.
CSV и три агента
Я собрал существующие ТК в CSV и передал эту базу агентам. Хотел получить структурированный разбор: возможные дубли, лишние проверки, кейсы на доработку и предложения о недостающем покрытии.
Это важное отличие от генерации с нуля. Когда база уже существует, новый кейс может оказаться ещё одной версией старого. А попытка «улучшить» старую проверку может незаметно превратить её в другой сценарий.
Поэтому результатом должен был стать черновик решений. Сначала агент предлагает, затем я проверяю: это действительно дубль или отдельная ветка? Кейс устарел или просто плохо описан? Недостающая проверка следует из требования или модель решила, что так обычно бывает?
Для этой работы сделали три роли:
Федя — аналитик. Разбирает запрос и найденные источники. Предлагает, какие существующие кейсы подходят под задачу и какие новые проверки обоснованы.
Серёга — автор ТК. По предложениям Феди пишет название, предусловие, шаги и ожидаемый результат. При разборе старой базы может выдавать решение о сохранении или необходимости уточнения вместо свободного переписывания.
Саша — рецензент. Проверяет кандидатов и одобряет либо отклоняет их. В коде он Александр.
Отдельный запрос на генерацию проходит ту же цепочку: Федя анализирует, Серёга пишет, Саша проверяет. Между стадиями работают проверки на Python, затем формируется выгрузка. Внутреннее APPROVE означает только прохождение этой стадии, а не мою окончательную приёмку.
Основа — Python, CrewAI, API языковой модели, ChromaDB и Sentence Transformers. Подключить эти компоненты оказалось только началом работы.
Ниже сокращённая схема ролей в CrewAI. Адаптер GigaChat передаётся в llm; поиск, промежуточные проверки и экспорт в этот фрагмент не включены.
def build_crew(llm, requirement, evidence):
from crewai import Agent, Crew, Process, Task
# llm — уже настроенный адаптер GigaChat для CrewAI.
common = dict(llm=llm, allow_delegation=False, verbose=False)
fedya = Agent(
role="Федя — аналитик",
goal="Найти обоснованные изменения и пробелы покрытия.",
backstory="Сверяешь требования с кейсами, сохраняешь ссылки и ID.",
**common,
)
serega = Agent(
role="Серёга — автор ТК",
goal="Подготовить кейсы без неподтверждённых деталей.",
backstory="Пишешь предусловия, шаги и результат по источникам.",
**common,
)
sasha = Agent(
role="Саша — рецензент",
goal="Проверить каждого кандидата по требованию и источникам.",
backstory="Обосновываешь APPROVE или REJECT, не меняешь ID.",
**common,
)
analysis = Task(
description=f"Требование: {requirement}\nИсточники: {evidence}",
expected_output="Предложения с исходными ID и обоснованиями.",
agent=fedya,
)
design = Task(
description="Подготовь кандидатов только по принятым предложениям.",
expected_output="Кейсы с уникальными ID, шагами и результатом.",
agent=serega, context=[analysis],
)
review = Task(
description="Верни по каждому ID вердикт и непустое обоснование.",
expected_output="JSON-массив: id, verdict (APPROVE/REJECT), reason.",
agent=sasha, context=[analysis, design],
)
return Crew(agents=[fedya, serega, sasha],
tasks=[analysis, design, review], process=Process.sequential)
Сводка ниже относится к другому сохранённому реальному запуску, а не к учебному примеру из статьи.
Этап сохранённого запуска | Результат |
|---|---|
Исходные предложения Феди | 2 |
Принято предложений Феди | 1 |
Кандидаты Серёги | 1 |
Вердикты Саши | 1 APPROVE, 0 REJECT |
Кейсы в финальной выгрузке | 1 |
Статус | SUCCESS |
Сохранённый прогон: два исходных предложения, одно принято, один финальный кейс. Сводка trace, не скриншот терминала.
Документация и поиск
Для той задачи, которую я хотел поручить агентам, готовой документации у нас не было. Её я составлял вручную и сохранял в текстовых файлах. Без этого агентам оставалось бы ориентироваться на старые кейсы, а в них как раз и требовалось разобраться.
Но наличие текстового файла ещё не означало, что модель правильно использует его содержимое. Передавать весь объём под каждый запрос тоже неудобно. Я предложил разбить документацию на чанки: искать подходящие части и отправлять модели их, а не всё целиком.
У идеи было две цели: экономить токены и точнее доставать нужную информацию под конкретную задачу. Точную экономию мы не замеряли, поэтому процентов здесь не будет.
В индексаторе текст делится по предложениям. У фрагментов сохраняются имя файла и номер части. Затем запрос преобразуется в эмбеддинг, а поиск выбирает близкие фрагменты из базы. Это позволяет вернуться к источнику и проверить, откуда взялось утверждение.
В этой упрощённой функции видно разбиение по предложениям, перекрытие и сохранение имени файла с номером части. Ориентир — 400 символов, не строгий предел длины.
import re
def chunk_document(text, source="notes_rules.txt", limit=400):
if limit <= 0:
raise ValueError("Размер фрагмента должен быть положительным")
sentences = [s.strip() for s in re.split(r"(?<=[.!?])\s+", text.strip())
if s.strip()]
chunks, current = [], []
for sentence in sentences:
candidate = " ".join(current + [sentence])
if current and len(candidate) > limit:
chunks.append(" ".join(current))
current = current[-2:] # Перекрытие — два предложения.
current.append(sentence)
if current:
chunks.append(" ".join(current))
# Из-за перекрытия и длинных предложений размер может быть > limit.
return [
{"text": chunk, "source": source, "chunk_idx": index}
for index, chunk in enumerate(chunks)
]
Казалось бы, документация подготовлена, нужный фрагмент найден — можно писать кейс. Но именно здесь появился следующий слой проблем: найденный текст мог относиться к соседней операции или другой роли. А даже подходящий фрагмент модель могла применить неправильно.
Чанки помогли организовать доступ к материалу. Гарантированного точного прочтения они не дали. Пришлось отдельно проверять и поиск, и то, что агент написал по его результатам.
Предусловия
Агенты регулярно добавляли в предусловия то, чего не было в подтверждённых материалах. Получался вполне аккуратный кейс, но с неподходящей исходной ситуацией. Проверка начиналась уже не там, где нужно.
Я перечитывал результат, сверял с источником и правил руками. Затем мы разбирались, как неподтверждённое условие прошло дальше: что получил агент, на какой материал сослался, какие проверки сработали и почему рецензент это одобрил.
Не всегда проблема была в явной выдумке. Иногда модель переносила условие из похожего кейса. Само по себе оно могло быть верным для того кейса, но не подходить под текущий запрос. Поэтому одной проверки «такой текст есть в базе» недостаточно.
Ещё один неприятный момент: Саша не был надёжной страховкой от Серёги. Он мог одобрить правдоподобный результат и написать убедительное обоснование, которое не совпадало с самим сценарием.
Повторные инструкции тоже не всегда помогали. Мы возвращали проблемные кандидаты на исправление, уточняли ограничения и снова проверяли ответ. В сохранённых экспериментах есть случай, где два раунда исправления не убрали выявленные нарушения.
Поэтому написать в статье «добавили строгий промпт и победили галлюцинации» я не могу. В нашем проекте это не сработало. Понадобилось менять то, какую часть работы вообще разрешено делать модели.
Проверки в коде
Существующий кейс перестали восстанавливать по памяти модели
Если на входе уже есть проверенная запись, незачем просить LLM заново сочинять её поля. Для существующих кейсов закрепили связь с исходным ID, а основу стали собирать программно из конкретной записи.
Так модель не должна незаметно подменять источник похожим кейсом или терять часть шагов при пересказе. Новые проверки и работа с существующими получили разные правила обработки и экспорта.
Точные факты вынесли из свободной генерации
В части экспериментов использовали карточки подтверждённых фактов: предусловия, действия и ожидаемый результат. Модель выбирает и планирует проверку, а точные поля собирает Python из разрешённого материала.
Это ограничило места, где агент мог что-то дописать. Но карточки не появились сами собой: их подготовка тоже требовала работы. Такой результат нельзя выдавать за универсальное понимание любой документации.
Начали проверять и самого проверяющего
Саша должен вернуть ровно один вердикт на каждого переданного кандидата. Если он пропустил ID, ответил дважды или указал неизвестный, код считает это ошибкой. После одобрения остаются проверки источников и соответствия требованию.
Так отдельно проверяется уже разобранный JSON с вердиктами Саши: один ответ на каждый ID, без повторов и неизвестных кандидатов. Смысл решения APPROVE эта функция не проверяет.
def approved_ids(candidate_ids, verdicts):
ids = list(candidate_ids)
if not ids or any(not isinstance(i, str) or not i.strip() for i in ids):
raise ValueError("Нужны непустые строковые ID кандидатов")
if len(ids) != len(set(ids)):
raise ValueError("ID кандидатов повторяются")
expected, seen, approved = set(ids), set(), set()
for item in verdicts:
if not isinstance(item, dict):
raise ValueError("Вердикт должен быть объектом")
case_id = item.get("id")
if not isinstance(case_id, str) or case_id not in expected:
raise ValueError(f"Неизвестный ID: {case_id!r}")
if case_id in seen:
raise ValueError(f"Повторный вердикт: {case_id}")
verdict, reason = item.get("verdict"), item.get("reason")
if verdict not in ("APPROVE", "REJECT"):
raise ValueError(f"Недопустимый вердикт: {verdict!r}")
if not isinstance(reason, str) or not reason.strip():
raise ValueError(f"Нет обоснования для {case_id}")
seen.add(case_id)
if verdict == "APPROVE":
approved.add(case_id)
missing = expected - seen
if missing:
raise ValueError(f"Нет вердиктов: {sorted(missing)}")
return approved
Дубли тоже пришлось искать по содержанию. Разные названия и группы не делают два кейса разными, если предусловия, действия и результат совпадают. Проверять только заголовки здесь мало.
Регрессии и релизная проверка
Смотрели не только итоговый HTML. Сохраняли ответы агентов и решения стадий, сравнивали их с CSV, разбирали причины отклонений. Если появлялся воспроизводимый дефект, добавляли регрессионную проверку и возвращались к нему после изменений.
В тестах закрепили конкретные проблемы: неправильную роль в источнике, подмену редактирования созданием, неподтверждённое действие в интерфейсе, смену смысла отрицательного требования и ошибки идентификаторов.
В ранней приёмке два одинаковых по поведению кейса попали в финальный CSV, хотя конвейер завершился успешно. У них совпали предусловия, шаги и ожидаемый результат, но различались названия.
Поле двух строк сохранённого CSV | Сравнение |
|---|---|
Название | Различается |
Предусловие | Совпадает |
Сценарий | Совпадает |
Ожидаемый результат | Совпадает |
Две строки реального сохранённого CSV: три поля совпадают, названия различаются. Содержимое рабочих кейсов исключено.
После доработки повторная проверка того же требования дала один соответствующий ему кейс. Этот результат сохранился в релизном пакете.
Для одной из зафиксированных релизных сборок сохранился журнал полного набора: 178 тестов — OK. Это число относится к той версии. Оно не означает, что любая новая генерация будет правильной или что ручное ревью больше не нужно.
Длинные прогоны разбили на короткие пакеты с контрольными точками. После обрыва можно продолжить работу, а не повторять всё с начала. Для разбора большой базы это тоже было важной частью решения.
Пример на вымышленных данных
Этот набор создан с нуля для статьи. Продукт, требования и кейсы вымышлены. Это не копия рабочей выгрузки с заменёнными названиями. Ниже ожидаемый разбор автора, а не результат реального запуска агентов.
Условный продукт — личный список заметок. Для примера зададим четыре правила:
Активную заметку можно редактировать.
Архивную заметку редактировать нельзя.
Активную заметку можно архивировать, архивную — восстановить в активные.
Сохранить заметку с пустым названием нельзя.
Представим CSV со следующими полями: ID, название, предусловие, шаги и ожидаемый результат. В таблице ниже показано его содержимое.
ID | Кейс | Предусловие | Действие и результат |
|---|---|---|---|
S001 | Изменение названия активной заметки | Есть активная заметка. | Изменить название на непустое и сохранить. Новое название сохранено. |
S002 | Сохранение нового названия заметки | Есть активная заметка. | Изменить название на непустое и сохранить. Новое название сохранено. |
S003 | Запрет редактирования архивной заметки | Есть активная заметка. | Попытаться изменить название и сохранить. Изменения запрещены. |
S004 | Редактирование заметки в архиве | Есть архивная заметка. | Попытаться изменить название и сохранить. Ожидаемый результат не заполнен. |
S005 | Архивация заметки | Есть активная заметка. | Архивировать. Заметка находится в архиве. |
S006 | Восстановление заметки | Есть архивная заметка. | Восстановить. Заметка находится среди активных. |
Здесь можно проверить сразу несколько вещей. S001 и S002 описывают одинаковое поведение — это кандидаты на объединение. В S003 предусловие противоречит названию и требованию: вместо архивной заметки указана активная. S004 нельзя принять без ожидаемого результата; после уточнения его нужно сравнить с исправленным S003, чтобы не сохранить ещё один дубль.
При этом в наборе нет отдельной проверки пустого названия, хотя правило явно есть в учебной документации. Это обоснованный пробел покрытия: агенту не нужно выдумывать новое ограничение, достаточно сопоставить правила и имеющиеся кейсы.
От Феди ожидаем предложения по дублям и покрытию. От Серёги — черновики исправлений и недостающей проверки. От Саши — проверку этих кандидатов. Затем QA сравнивает результат с четырьмя заданными правилами и принимает изменения. Неуказанный текст сообщения об ошибке или конкретную кнопку модель придумывать не должна.
Такой пример можно воспроизвести отдельно от рабочего проекта. Но до реального запуска нельзя приписывать агентам этот ожидаемый результат или писать, что они нашли все проблемы.
Результаты и время
Агенты помогли перейти от общей проблемы «в базе накопилось много кейсов» к конкретным предложениям: эти проверки похожи, здесь неполный результат, тут нужно уточнить требование, а описанная функциональность пока не покрыта.
После релиза около 300 кейсов суммарно убрали как неактуальные или доработали. Это не 300 удалений: часть проверок сохранили и исправили. Также добавили 150 новых кейсов и обнаружили функциональность, для которой не хватало проверок.
Это результат работы агентов вместе с человеком. Они готовили предложения, я проверял их, исправлял ошибки и принимал изменения. Эти числа не означают, что система автономно очистила базу. Итоги привожу по результатам своей работы; с техническим прогоном релизных тестов это разные показатели.
По моим наблюдениям, генерация занимала примерно 3–8 минут. Проверка и исправление шли после неё и зависели от количества кейсов. Это время получения черновика, а не обещание разобрать всю базу за несколько минут.
Процент ускорения я не измерял. Для меня полезность была в том, что появился материал для предметного ревью. Но если принять его без проверки, можно просто добавить в базу новые ошибки вместо старых.
Ограничения
Агенты не подходят для автономной работы. Они могут добавлять от себя, а затем одобрять ошибки друг друга. Одними промптами надёжно убрать это не получилось. Программные ограничения помогли с проверенными классами проблем; ручную приёмку они не отменили.
Подготовка источников занимает время. Документацию пришлось писать, делить на фрагменты и проверять её использование. Если в ней нет ответа, правильным результатом может быть вопрос к команде, а не ещё один уверенно написанный кейс.
Есть сложности с доступом к API. В моём Windows-окружении попытки подключить некоторые зарубежные API через VPN и туннелирование не дали нужного результата: терминальные запросы продолжали выходить через российский маршрут. Пришлось использовать доступный в моём окружении API. Это мой опыт конкретной настройки, а не утверждение, что маршрутизацию на Windows невозможно настроить вообще.
Первый разбор я отдал агентам, а предусловия до сих пор перечитываю сам.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.