Нейросеть пишет грамотно, и именно поэтому её текст узнаётся с первой строки: длинные тире, конструкции «дело не в X, а в Y», “важно понимать”, «давайте разберёмся». Я столкнулся с этим на Телеграм‑боте, который превращает голосовые сообщения в сценарии для соцсетей, и чинил проблему в три слоя за один день. В статье разберу, как устроен пайплайн бота, почему просьбы в промпте не хватило, почему второй проход нейросети тоже не спас и что в итоге закрыло задачу. Весь код ниже взят из рабочего репозитория, имена переменных я чуть упростил.
Что делает бот
Пользователь у бота один: автор, который ведёт блог и не хочет садиться за клавиатуру. Он наговаривает мысль голосом, выбирает формат (сторис‑сценарий, карусель или пост), и бот присылает готовый текст в его манере. В конце текста стоит блок с заданием для монтажёров и SMM‑щиков: формат, тема, что сделать, дедлайн. Когда автор нажимает «Принять», бот спрашивает дедлайн и отдельным сообщением шлёт карточку «ЗАДАНИЕ ГОТОВО», которую можно сразу переслать в рабочий чат.
Стек получился такой:
Python 3.11, aiogram 3 (
aiogram>=3.17,<4);распознавание речи: Whisper
whisper-large-v3-turboчерез API Groq;генерация: Claude Sonnet 4.6 через официальный SDK
anthropic, роутинг намерений в свободном чате на Claude Haiku 4.5;хранение: SQLite в файле на Docker‑томе;
деплой: один контейнер на VPS, выкладка через Coolify по пушу в
main.
Пайплайн: от голосового до текста
Получение аудио
Бот принимает голосовые, аудиофайлы, видео и кружочки. Файл он скачивает целиком в память и отдаёт в Groq, промежуточных файлов на диске нет:
file = await bot.get_file(file_id) audio_bytes = (await bot.download_file(file.file_path)).read() text = await transcribe_voice(audio_bytes, config.GROQ_API_KEY, filename)
Модуль распознавания занимает полтора десятка строк:
async def transcribe_voice(audio_bytes: bytes, api_key: str, filename: str = "voice.ogg") -> str: result = await _get_groq(api_key).audio.transcriptions.create( file=(filename, audio_bytes), model="whisper-large-v3-turbo", language="ru", ) return result.text
Имя файла бот подставляет по типу сообщения: голосовое уходит как voice.ogg, видео и кружочки как video.mp4, а для аудиофайла берётся оригинальное имя из message.audio.file_name. По расширению API понимает, в каком формате пришли байты.
Проверки размера в коде нет, и это осознанная лень: Bot API не отдаёт ботам файлы больше 20 МБ, а автор не наговаривает голосовые на полчаса. Если начнёт, бот упадёт на get_file, и эту ветку придётся дописать.
К расшифровке бот добавляет метку источника ([Голосовое], [Видео], [Текст]), а если пользователь прислал ссылку, сначала скачивает статью через httpx и вытаскивает текст BeautifulSoup. Всё это кладётся в данные FSM как input_text.
Состояния
Диалог держится на конечном автомате aiogram. Для каждого формата есть три состояния: ввод, выбор количества кадров или слайдов и цикл правок:
choosing_format → storytelling_input → storytelling_count → storytelling_edit ─┐ → carousel_input → carousel_count → carousel_edit ─┤ → post_input → post_edit ─┤ ↓ «Принять» → assignment_deadline → карточка → choosing_format
В состоянии *_edit автор может прислать правку текстом или голосом («убери третий слайд, добавь пример про работу»), и бот прогоняет текущий вариант через apply_edit с низкой температурой. Хранилище FSM у меня MemoryStorage, поэтому при перезапуске контейнера незаконченный диалог теряется. Для одного пользователя это терпимо, но про Redis я уже думаю.
Сборка промпта
Промпты лежат в одном файле prompts.py весом 64 КБ. Каждый формат собирается f‑строкой из трёх частей: общий контекст автора (кто он, тон, словарь, запреты, около 3 КБ), описание формата и правила против ИИ‑маркеров в самом конце:
def get_carousel_prompt(slides: int) -> str: return f"""{AUTHOR_CONTEXT} ## ЗАДАЧА Сделай карусель из {slides} слайдов по теме из сообщения пользователя. ... {ANTISLOP_RULES}"""
Правила стоят в конце намеренно: на моих текстах модель слушалась последних инструкций заметно лучше, чем инструкций из середины длинного промпта.
Блок задания для ассистентов модель генерирует сама, по шаблону из промпта:
? ЗАДАЧА ДЛЯ АССИСТЕНТОВ: Формат: Сторителлинг ({frames} сторис) Тема: [одна строка] Что сделать: снять все кадры по инструкциям выше, смонтировать сторис, опубликовать пост Дедлайн: ___
А итоговую карточку «ЗАДАНИЕ ГОТОВО» собирает уже код, без нейросети: тему бот берёт из состояния, дедлайн из ответа пользователя, а чек‑лист из словаря по формату. Тут нейросеть не нужна, и её отсутствие убирает целый класс ошибок.
Вызов Claude
message = await _get_claude().messages.create( model="claude-sonnet-4-6", max_tokens=max_tokens, extra_body={"temperature": temperature}, system=[{"type": "text", "text": system, "cache_control": {"type": "ephemeral"}}], messages=[{"role": "user", "content": user_content}], )
Системный промпт передаётся списком блоков с cache_control, потому что контекст автора плюс формат весит несколько тысяч токенов и повторяется в каждом запросе. Кэш живёт пять минут, а автор обычно делает генерацию и две‑три правки подряд, так что повторные запросы читают промпт из кэша по цене в десять раз ниже обычной.
Температуру я подбирал под задачу: генерация структуры 0,7, правки 0,3, чистка 0,2. Почему температура передаётся через extra_body, расскажу ниже, это отдельная история.
Длинный ответ бот режет на сообщения по разделителю ━━━━━━━━━━━━━━━━━━━━, который модель ставит между частями сценария, и собирает куски до 4000 символов (лимит Telegram 4096). Если одна секция длиннее лимита, она делится по строкам.
Главная проблема: ИИ‑маркеры
Первые версии писали чисто и гладко, но любой человек, который проводит в соцсетях больше часа в день, узнавал в них нейросеть. Маркеров было три группы:
длинные тире там, где живой человек поставил бы точку или запятую;
антитезы: «это не X, это Y», “не X, а Y”, “дело не в X, а в Y”, “X, а не Y”;
клише и ритм: «важно понимать», «стоит отметить», тройки прилагательных, предложения по 30 слов.
Слой 1: правило в промпте
Сначала я написал жёсткий чек‑лист с примерами «было/стало» и поставил его в промпт группового чата. Через пару дней выяснилось, что генераторы карусели, поста и сторис про него вообще не знали: я добавил правило в одно место, а путей генерации в боте больше десятка. Правило переехало в константу ANTISLOP_RULES, которая дописывается в конец каждого промпта, а самое важное я вынес первым заголовком в контекст автора:
## ГЛАВНОЕ ПРАВИЛО ПИСЬМА (нарушать нельзя, проверяй каждое предложение) Никаких длинных тире (—). Никаких антитез «это не X, это Y» / «не X, а Y» / «дело не в X, а в Y» / «X, а не Y». Это два главных ИИ-маркера.
Стало лучше, но модель всё равно регулярно срывалась, и чаще всего на длинных сценариях, где правило оказывалось в тысячах токенов от того места, где она писала текст.
Слой 2: второй проход‑чистильщик
Следующая идея: готовый текст отправляется в ту же модель второй раз с другим системным промптом. Роль у неё узкая, «редактор‑чистильщик»: убрать маркеры и не трогать остальное. Ключевые строки промпта:
Это второй проход: чистишь, не переписываешь заново и не сокращаешь. ... Сохрани всю структуру и разметку: заголовки, «Слайд N», «Сторис N», значки ???????, разделители ━━━, нумерацию, эмодзи, блок «ЗАДАЧА ДЛЯ АССИСТЕНТОВ». Объём: выходной текст примерно равен входному. Верни ТОЛЬКО очищенный текст целиком.
Для антитез в промпте есть пары «было/стало» и отдельная фраза «проверяй ОБА порядка», потому что «X, а не Y» модель пропускала чаще, чем «не X, а Y». Температура 0,2, лимит 8000 токенов, чтобы длинный сценарий не обрезался посередине.
Второй проход ловил большую часть антитез, но тире продолжали проскакивать. Причина простая: языковая модель работает с токенами, и «убери все символы U+2014» для неё такая же мягкая просьба, как «пиши короче». Иногда она меняла тире на запятую, а иногда сама же ставила новое в переписанном предложении.
Слой 3: детерминированный фильтр
Тут я понял, что задачу с проверяемым результатом надо решать кодом. Тире удаляются регуляркой, и эта функция стоит на каждом выходе бота:
_AI_DASHES = "—–―−‒" # em, en, horizontal bar, minus, figure dash _AI_DASH_RE = re.compile(f"[{_AI_DASHES}]") def strip_ai_markers(text: str) -> str: if not text: return text text = text.replace(" -- ", " - ") return _AI_DASH_RE.sub("-", text)
В набор попали пять символов, потому что модель ставит не только длинное тире: встречались среднее тире, знак минуса U+2212 и даже горизонтальная черта U+2015. Всё меняется на обычный дефис, так что диапазон «1–3» превращается в «1-3» и не ломается. Из набора я намеренно исключил ━, на этом символе держатся разделители секций и нарезка длинных сообщений.
С антитезами регуляркой так не получится: «не X, а Y» нельзя механически превратить во что‑то осмысленное, предложение нужно переписать. Поэтому код антитезы только ищет:
_ANTITHESIS_RES = ( re.compile(r"это\s+не\s+[^,.!?\n]{1,40},\s*это\s+", re.IGNORECASE), # это не X, это Y re.compile(r"(?:^|[\s(])не\s+[^,.!?\n]{1,40},\s*а\s+", re.IGNORECASE), # не X, а Y re.compile(r"дело\s+не\s+в\s+[^,.!?\n]{1,40},\s*а\s+в\s+", re.IGNORECASE), # дело не в X, а в Y re.compile(r",\s*а\s+не\s+", re.IGNORECASE), # X, а не Y ) def _has_antithesis(text: str) -> bool: return any(r.search(text) for r in _ANTITHESIS_RES)
Ограничение [^,.!?\n]{1,40} держит совпадение внутри одного предложения и не даёт регулярке склеить «не» из начала абзаца с «а» через три строки. Ложные срабатывания всё равно бывают, например на «это не его задача, это задача монтажёра», и именно поэтому детектор ничего не удаляет сам. Он только запускает одну прицельную попытку переписать текст, а вслепую вырезанная фраза сломала бы смысл.
Финальная функция выглядит так:
async def antislop_pass(text: str) -> str: text = strip_ai_markers(text) # детерминированно, всегда, до всего остального if not config.ANTISLOP_SECOND_PASS: return text try: cleaned = strip_ai_markers((await _generate_claude( text, get_antislop_pass_prompt(), max_tokens=8000, temperature=0.2)).strip()) or text if _has_antithesis(cleaned): # одна прицельная повторная попытка retry = strip_ai_markers((await _generate_claude(cleaned, get_antislop_pass_prompt() + "\n\nВ тексте ОСТАЛИСЬ антитезы «не X, а Y» / " "«это не X, это Y». Перепиши их напрямую, без противопоставления.", max_tokens=8000, temperature=0.2)).strip()) if retry: cleaned = retry if _has_antithesis(cleaned): logging.warning("antislop: antithesis still present after one retry") return cleaned except Exception: return text # тире уже вычищены
Порядок здесь важен. Фильтр стоит первым, чтобы при любом сбое API пользователь получил текст без тире. После каждого ответа модели фильтр срабатывает снова, потому что модель может вернуть тире в переписанном куске. Повторная попытка ровно одна: если антитеза пережила два прохода, это почти всегда ложное срабатывание, и третий вызов только потратит токены. Такой случай я пишу в лог и разбираю руками.
Второй проход можно выключить флагом ANTISLOP_SECOND_PASS: тогда остаётся только фильтр, а пользователь не ждёт лишний вызов модели.
Пути, которые обходили фильтр
Когда фильтр был готов, я прошёлся по коду и нашёл шесть мест, которые отдавали пользователю текст модели напрямую, минуя antislop_pass: ответы советника, рецензия текста, ответы в групповом чате, ежедневная шутка для команды, темы и описания для YouTube. Сам по себе второй проход им был не нужен, но тире там проскакивали так же. Теперь каждый выход из модели проходит хотя бы через strip_ai_markers, а в групповом чате, где ответ должен прийти быстро, на этом всё и заканчивается.
Все три слоя я сделал за один день, 7 июня, в трёх коммитах подряд. Если бы начал сразу с третьего, сэкономил бы полдня: требование, которое проверяется кодом, нужно проверять кодом, а промпт оставлять для того, что кодом не проверишь, то есть для голоса, тона и структуры.
Чего фильтр не умеет
Сохранение разметки во втором проходе держится только на инструкции в промпте, программной проверки нет. Если модель однажды потеряет блок задания, я узнаю об этом от пользователя. Правильнее сравнивать количество маркеров ?, ━━━ и строк «Слайд N» до и после чистки и при расхождении отдавать текст первого прохода. В соседнем модуле, который чистит документы целиком, такая проверка уже есть: если после чистки осталось меньше 60% слов, кусок отправляется на повторную попытку, а после второй неудачи остаётся оригинал.
Длину предложений и клише код тоже не проверяет. Клише можно было бы ловить по словарю, но их варианты бесконечны, и я оставил это модели.
Вторая поломка: бот умер после пересборки
В сентябре бот перестал отвечать на все кнопки разом, хотя код я не трогал: Coolify просто пересобрал образ. В requirements.txt стояло anthropic>=0.40.0, при сборке приехал SDK версии 1.x, и каждый вызов messages.create(..., temperature=0.7) стал падать на сигнатуре метода.
Сам API параметр по‑прежнему принимает, поэтому починка заняла один коммит: температура переехала в extra_body, который SDK передаёт в тело запроса как есть, а версия SDK теперь ограничена диапазоном anthropic>=1,<2. Остальные зависимости (groq, httpx, beautifulsoup4) до сих пор указаны только с нижней границей, и следующая такая поломка придёт оттуда. Правильное решение здесь lock‑файл с точными версиями, и он стоит у меня первым в списке дел.
Хранение и деплой
История генераций живёт в SQLite. Отдельный сервер базы данных для одного пользователя не нужен, а файл на Docker‑томе переживает пересборку образа:
CREATE TABLE IF NOT EXISTS generations ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, username TEXT, format TEXT, topic TEXT, content TEXT, created_at TEXT DEFAULT (datetime('now','localtime')) );
Работаю я со стандартным sqlite3, а не с aiosqlite: каждый вызов открывает соединение и уходит в asyncio.to_thread, чтобы не блокировать цикл событий. Новые колонки добавляются миграцией через ALTER TABLE в try/except. Это примитивно, но при одном файле базы и одном процессе работает.
Доступ к боту закрыт middleware с белым списком ID пользователей и чатов, всё остальное он молча игнорирует и пишет в лог:
class AuthMiddleware(BaseMiddleware): async def __call__(self, handler, event, data): uid = getattr(data.get("event_from_user"), "id", None) cid = getattr(data.get("event_chat"), "id", None) if uid in self.allowed_users or cid in self.allowed_chats: return await handler(event, data) logging.info("Blocked update from user_id=%s chat_id=%s", uid, cid) return None
Он подключён как dp.update.outer_middleware, так что срабатывает до любого хендлера и до FSM.
Dockerfile короткий, но в нём есть одна неочевидная строка:
FROM python:3.11-slim RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY bot/requirements.txt ./requirements.txt RUN pip install --no-cache-dir -r requirements.txt COPY bot/ ./bot/ WORKDIR /app/bot CMD ["sh", "-c", "sleep 35 && python main.py"]
sleep 35 нужен из‑за того, как Coolify выкатывает новую версию: новый контейнер стартует, пока старый ещё работает. Два процесса с одним токеном одновременно вызывают getUpdates, Telegram отвечает ошибкой Conflict, и один из них падает. Пауза даёт старому контейнеру время остановиться. На вебхуках этой проблемы бы не было, но поднимать ради одного пользователя HTTPS‑эндпоинт я не стал.
Что бы я сделал иначе
Зафиксировал бы версии всех зависимостей с первого дня через lock‑файл. Для бота с одним пользователем «последняя версия» не даёт ничего, кроме внезапных падений.
Сохранял бы в базе исходную расшифровку рядом с результатом. Сейчас в таблицу пишется только итоговый текст, и когда результат получился неудачным, не понять, кто виноват: Whisper или Claude.
Начал бы с детерминированного фильтра, а промпт и второй проход добавлял бы потом. Регулярка на тире пишется за пять минут и не ошибается.
Написал бы хотя бы пару тестов на
strip_ai_markersи_has_antithesis. Сейчас их нет, и регулярки я проверял ручным прогоном на текстах, где модель срывалась. Набор таких текстов уже есть, осталось завернуть его в pytest.
Итог
Бот работает с мая 2026 года, в репозитории 59 коммитов. Схема с тремя слоями переносится на любой генератор текста: промпт задаёт голос и структуру, второй проход модели переписывает то, что требует понимания смысла, а код гарантирует то, что можно проверить формально. Если вы делаете что‑то похожее, начните с третьего слоя.
Комментарии (3)

ToxaBes
05.10.2026 15:23О каком вычищении ИИ-маркеров может идти речь, если вы не смогли вычистить до конца даже эту статью? У вас блоки ИИ-слопа просто перемешаны с авторским текстом.
Есть определенные маркеры монотонности, которые кодом не вычистить, т. к. нужно менять весь абзац.
KrimsN
Негативные инструкции в промпте частично работают как праймер. Фраза «никаких антитез «не X, а Y»» подсовывает модели ровно этот паттерн. Обычно лучше дать позитивную формулировку («утверждай прямо: сразу называй, что есть») и один-два примера «было, стало».
Так же советую разбить процесс на 4 этапа:
Детерминированный слой: тире, двойные дефисы, нормализация пробелов.
Детектор: паттерны с весами плюс метрики, выдаёт список попаданий с позициями. (Не блокировать сразу если есть конструкции по типу
не X, а Y, а собирать веса, насколько это ai текст)Если счёт ниже порога, второй проход не вызывается совсем (экономия в большинстве случаев).
Если выше, в LLM уходят только предложения с попаданиями (плюс соседнее для контекста), и результат подставляется обратно по позициям. Заголовки, эмодзи и разделители при этом не затрагиваются, потому что вы их вообще не отправляли.
Потому что всегда отправлять второй проход очень дорого и рискованно. Это +1 вызов, +2-10 секунд, расход токенов на весь объём и риск дрейфа: модель может тихо поменять структуру или вставить новые штампы.
Так же советую рассмотреть библиотечку
pymorphy3, чтобы быстро искать разные клише по леммам, плотность причастных оборотов и однообразие предложений.KrimsN
Обязательно поработайте над негативными инструкциями. Это как говорить человеку, не думай о том что видишь нос или что каждые 5 секунд ты делаешь вдох и потом выдох, вроде попросил не думать о корове, но после этого человек не может думать ни о чём крове коровы)
LLM на протяжении всего размышления и генерации держит в голове этот паттерн и шанс того, что она его куда-нибудь случайно пропихнёт растёт