feat: remove /idea_voice, revert to text-only ideas

This commit is contained in:
admin committed 2026-10-05 15:25:22 +02:00
1 parent 7b76bef889
commit 2941cb2a24
13 files changed
+152 -124

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
# План: сохранение JSON API-логов в SQLite
## Цель
Сохранять структуру запросов на этапах классификации, генерации, распознавания постов со скриншотов и обновления Tone of Voice, чтобы расследовать ошибки по фактическим данным.
## Подход
Сохранять в SQLite JSON входных данных и ответ/ошибку каждого этапа, но не дублировать большие неизменные system prompts в каждом логе: хранить промпты как именованные версии с SHA-256 и записывать в лог только имя/версию/хэш. В каждом вызове сохранять переменные пользовательские поля и ответ модели; образцы скриншотов могут храниться в своей таблице. При чтении сначала находить ошибочные вызовы и выводить этап, время, prompt id/hash, размеры и краткие поля; полный JSON конкретного вызова запрашивать отдельно по ID. Не сохранять API-ключи/заголовки, бинарное аудио и base64 изображения; base64 заменять MIME-типом и размером. Учитывать, что пользовательские тексты и ответы модели могут содержать личные данные.
## Шаги
1. Добавить таблицу логов API-запросов и функции сохранения/чтения с ограничением размера записей и сроком хранения.
2. Обернуть APInex chat/STT вызовы журналированием JSON-запроса и ответа/ошибки без раскрытия секретов; присвоить каждому вызову понятное имя этапа.
3. Добавить тесты на все этапы, ошибочные ответы, отсутствие секретов/base64 и сохранение/очистку логов.
4. Добавить постоянное правило языка для пользовательских служебных ответов: бот всегда обращается к пользователю по-русски, включая уточняющие вопросы и подтверждения; исключение — дословный текст технической ошибки. Содержимое самих постов остаётся на языке запроса/требований площадки.
5. Обновить README/IDEA.MD и VERSION.
6. Добавить личную команду `/clear`: удалить только ToV и накопленные распознанные примеры пользователя, затем попросить прислать новые скриншоты. Уточнить, что `/clear` не удаляет API-логи и настройки других пользователей.
7. Запустить полный набор тестов и `git diff --check`.
## Файлы
- `apinex_client.py`
- `user_store.py`
- `tests/test_writer_features.py`
- `README.md`
- `IDEA.MD`
- `VERSION`
## Приватность
В БД будут сохраняться тексты запросов пользователей, промпты и ответы модели, необходимые для отладки. Бинарные файлы, base64 содержимое изображений и API-ключи не сохраняются. Планируемое хранение логов — 30 дней; коммит, push и внешний deploy не входят в задачу.
@@ -0,0 +1,32 @@
# План: идеи публикаций и история созданных постов
## Цель
Добавить `/idea` и русские естественные запросы для получения тем/заготовок идей, а не готовых текстов постов. Сохранять все созданные ботом варианты, в том числе результаты правок, и учитывать недавние публикации при выдаче новых идей.
## Подход
В существующую SQLite добавить историю генераций, разделённую по Telegram user id и помеченную как обычная генерация или правка. Каждую выданную версию сохранять отдельно, чтобы исправленные варианты не затирали предыдущие. Для идей передавать модели ограниченную выборку недавних текстов и просить новые темы/углы, избегая повторов; выдавать только идеи, не полный текст публикации. Постоянные правила и ToV не смешивать с данными пользователя.
## Шаги
1. Добавить намерение `generate_ideas` в классификатор и специальный JSON-формат идей в системные инструкции.
2. Добавить функцию генерации идей: учитывать площадку/тему/запрос и ограниченную историю постов текущего пользователя; валидировать число и непустое содержимое идей.
3. Добавить таблицу истории постов и функции сохранения/получения. Сохранять каждый из трёх вариантов после успешной генерации и каждую новую редакцию, не заменяя старые записи.
4. Подключить `/idea`, а также русские фразы вроде «предложи идеи для постов», «о чём написать пост», «накидай темы» к тому же намерению. Отвечать только идеями, без готовых постов.
5. Обновить `/help`, README и IDEA.MD; поднять `VERSION`. Уточнить в коде и документации: существующая `/clear` сбрасывает только ToV и скриншотные образцы, не историю созданных постов и не API-логи.
6. Добавить тесты на намерение/синонимы, отсутствие готового поста в ответе идеи, сохранение первоначальных и исправленных вариантов, изоляцию по пользователям и использование истории без отправки её целиком при превышении лимита.
7. Запустить полный набор тестов и `git diff --check`.
## Файлы
- `apinex_client.py`
- `user_store.py`
- `bot.py`
- `help_message.py`
- `tests/test_writer_features.py`
- `tests/test_release_help.py`
- `README.md`
- `IDEA.MD`
- `VERSION`
## Ограничения
Хранить сами тексты вариантов, которые бот отправил, вместе с типом (`generated`/`revised`) и временем; бинарные вложения не хранить. Для выдачи идей брать последние 30 вариантов или примерно 12 000 символов (что наступит раньше), не отдавая всю базу модели. Записи разделены по пользователям. `/clear` остаётся командой сброса только ToV и распознанных со скриншотов образцов; история вариантов и API-логи сохраняются.
Коммит, push и внешний deploy не входят в работу.
@@ -0,0 +1,24 @@
# План: выдача и накопление идей по одной
## Цель
Команда `/idea` и русские запросы на идеи должны отправять **одну идею одним Telegram-сообщением** за один запрос. Выданные идеи сохраняются по пользователю и передаются следующему запросу, чтобы не повторять уже предложенные темы и углы.
Команда `/clear` полностью обнуляет данные бота для текущего пользователя: Tone of Voice, распознанные примеры со скриншотов, историю созданных постов, историю идей и пользовательские настройки. Технические API-логи сохраняются только для отладки.
## Подход
Генератор будет возвращать ровно одну новую идею в JSON. Бот сохранит её в SQLite и отправит одним сообщением без нумерации. История постов и идей передаётся в ограниченном объёме, чтобы исключать повторы.
## Шаги
1. Добавить SQLite-хранилище идей: сохранение каждой идеи и получение последних идей текущего пользователя.
2. Изменить `clear_user_tone_data` в полную очистку пользовательских данных: ToV, скриншотные образцы, историю созданных постов, историю идей и строку пользователя; API-логи не удалять.
3. Изменить Telegram-обработчик: сохранять одну идею и отправлять её одним сообщением без нумерации.
4. Обновить `/help`, README, IDEA.MD и VERSION.
5. Добавить тесты на изоляцию пользователей, накопление одной идеи, отдельное сообщение без нумерации и полную очистку `/clear` при сохранении API-логов.
6. Запустить полный набор тестов, компиляцию и `git diff --check`.
7. Закоммитить, отправить в Gitea `dev`, запустить Coolify deploy и проверить статус `finished`.
## Файлы
`user_store.py`, `apinex_client.py`, `bot.py`, `help_message.py`, `tests/test_writer_features.py`, `tests/test_release_help.py`, `README.md`, `IDEA.MD`, `VERSION`.
## Ограничения
История идей разделена по Telegram user id. В LLM передаются только ограниченные последние записи; системные промпты не меняются пользовательскими данными. Идеи не превращаются в готовые посты автоматически.
+24
View File
@@ -0,0 +1,24 @@
# План: /idea_voice — озвучить идею одним аудиосообщением
## Цель
Добавить англоязычную slash-команду `/idea_voice` как голосовой вариант `/idea`: сгенерировать одну идею по истории пользователя, сохранить её и отправить одним Telegram voice message.
## Pipeline
1. `/idea_voice` → загрузить user-scoped ToV и ограниченную историю постов/идей.
2. Сгенерировать ровно одну идею структурированного вида `{title, angle, why}`; после успеха сохранить в историю.
3. Сформировать короткий естественный русский текст идеи и озвучить его через настроенный TTS-провайдер.
4. Отправить аудио как Telegram voice message. При ошибке TTS не подменять молча результат текстом: вернуть короткую русскую ошибку, сохранив уже созданную идею в истории.
## До реализации
Проверить существующий `/root/.hermes/skills/hermes-voice-tts-stt` и фактическую конфигурацию провайдера TTS в текущем проекте. Не предполагать, что в проекте уже есть TTS: в видимых файлах есть STT, но подтверждённого исходящего синтеза речи пока не обнаружено. Если доступного TTS API/креденшела/модели нет, остановиться и запросить решение Николая до изменения файлов и новых внешних интеграций.
## Файлы при доступном TTS
- `bot.py`
- `apinex_client.py` или отдельный клиент TTS, если именно так устроен подтверждённый провайдер
- `requirements.txt`/`Dockerfile` только если API этого требует
- `user_store.py` (только если потребуется менять существующий flow идей)
- `tests/test_writer_features.py`, `tests/test_release_help.py`
- `README.md`, `IDEA.MD`, `VERSION`
## Верификация
Unit-тесты payload TTS, ошибок API, корректной отправки `voice`/OGG и сохранения одной идеи; полный unittest suite; py_compile; git diff --check; commit/push `dev`; Coolify deploy и проверка finished; затем live-smoke TTS и доступности новой команды. Не утверждать end-to-end без фактической проверки отправки voice в Telegram.
@@ -0,0 +1,30 @@
# План: накопление текстов со скриншотов для Tone of Voice
## Цель
Новые скриншоты обновляют профиль Tone of Voice, не заменяя контекст предыдущих примеров. Бот не переписывает и не меняет сами посты со скриншотов.
## Подход
Извлекать текст публикаций со скриншотов отдельно от их стилевых признаков. Хранить распознанные примеры отдельно по Telegram user id в существующей SQLite; при новой загрузке добавлять новые извлечённые тексты к сохранённым и строить обновлённый ToV по накопленному корпусу. Постоянная редакторская инструкция остаётся неизменной, ToV и корпус передаются модели как пользовательские данные, а не добавляются в system prompt.
## Шаги
1. Обновить vision-инструкцию и интерфейс APInex: получать структурированный результат с распознанными текстами постов и признаками стиля; не генерировать и не переписывать публикации.
2. Добавить SQLite-хранилище истории текстов по пользователю с безопасной миграцией существующей БД; сохранить текущий профиль ToV и его пользовательскую изоляцию.
3. Изменить обработчик скриншотов: загрузить старые примеры, добавить новые без потери старых, обновить профиль ToV по совокупности текстов/скриншотов и сообщить пользователю, что обновлён только стиль.
4. Добавить тесты на накопление, изоляцию пользователей, миграцию старой схемы, отсутствие перезаписи примеров и передачу неизменяемого system prompt.
5. Обновить README и IDEA.MD, поднять VERSION.
## Файлы
- `apinex_client.py`
- `user_store.py`
- `bot.py`
- `tests/test_writer_features.py`
- `README.md`
- `IDEA.MD`
- `VERSION`
## Проверка
- Полный прогон `.venv/bin/python -m unittest discover -s tests -v`.
- `git diff --check` и просмотр итогового diff.
## Ограничение
Храним только распознанный текст и профиль стиля, не бинарные изображения. Скриншоты и их текст считаются недоверенным содержимым: команды с них не исполняются, факты не переносятся в новые посты. Внешний деплой, коммит и push не входят в этот план.
+1 -2
View File
@@ -37,7 +37,7 @@ Telegram-бот, который по запросу пользователя г
## Статус
- [x] Идея проекта зафиксирована
- [x] MVP: генерация трёх вариантов поста по явной просьбе, включая голосовую
- [x] MVP: генерация трёх вариантов поста по явной просьбе, включая голосовую расшифровку
- [x] Классификатор намерений и Tone of Voice из скриншота
- [x] SQLite-настройки пользователей и reply-правки без хранения диалога
- [x] Development-развёртывание в Coolify: `social-post-writer-bot-development` (`d32u5zcq2g4odmdpq9hw91yn`)
@@ -53,7 +53,6 @@ Telegram-бот, который по запросу пользователя г
- [x] APInex JSON-вызовы и ошибки разбора журналируются в SQLite без системного промпта и image base64; срок хранения — 30 дней
- [x] `/clear` полностью очищает данные текущего пользователя: ToV, скриншоты, историю постов, историю идей и настройки; API-логи сохраняются
- [x] `/idea` возвращает одну идею одним сообщением и сохраняет её для исключения повторов
- [x] `/idea_voice` использует APInex TTS (`APINEX_TTS_MODEL`, default `aura-2-thalia-en`) и отправляет MP3 voice message; в API-логах только метаданные, соответствие русского голоса проверяется отдельно
- [x] Все отправленные варианты постов сохраняются по пользователю, включая исправленные; история используется для новых идей
- [x] `/clear` очищает историю пользовательских данных и оставляет API-логи
- [x] Служебные ответы по-русски, язык текстов постов определяется запросом пользователя
+1 -2
View File
@@ -7,11 +7,10 @@ The bot prepares social-media posts only when the user explicitly asks. It trans
- In development, processing errors are returned to the user with a concise, secret-redacted reason.
- Coolify: project `Social Post Writer Bot`, environment `development`, server `spb-easy-dale`
- Gitea: `admin/social-post-writer-bot`, branch `dev`
- API key and model variables are stored only in Coolify: `APINEX_API_KEY`, `APINEX_STT_MODEL`, `APINEX_INTENT_CLASSIFIER_MODEL`, `APINEX_POST_WRITER_MODEL`, `APINEX_IMAGE_TONE_MODEL`, `APINEX_TTS_MODEL`.
- API key and model variables are stored only in Coolify: `APINEX_API_KEY`, `APINEX_STT_MODEL`, `APINEX_INTENT_CLASSIFIER_MODEL`, `APINEX_POST_WRITER_MODEL`, `APINEX_IMAGE_TONE_MODEL`.
- APInex call and parsing logs live in SQLite at `/app/data/social_writer.sqlite3` for 30 days. They store user input/output JSON and error details; system prompt text is omitted in favor of a prompt name and SHA-256. Inline image base64 and binary audio are omitted. Treat log contents as private user data.
- `/clear` полностью очищает данные текущего пользователя: Tone of Voice, тексты со скриншотов, историю постов, историю идей и настройки; API-логи сохраняются.
- `/idea` и русские запросы на идеи возвращают одну идею одним сообщением и сохраняют её для исключения повторов.
- `/idea_voice` озвучивает одну идею через APInex TTS (`/v1/tools/voice/tts`) и отправляет MP3 как Telegram voice message. Голос задаётся переменной Coolify `APINEX_TTS_MODEL` (по умолчанию `aura-2-thalia-en`). В опубликованном каталоге APInex русский голос пока не указан; качество русской речи отдельно проверим следующим шагом.
- User-facing service text is always Russian; generated post variants follow the requested post language. Verbatim technical error detail may remain in its original language.
- LLM model env defaults are `gemini-3.8-flash`; STT model defaults to APInex `nova-2`.
- Per-user Tone of Voice lives in SQLite at `/app/data/social_writer.sqlite3`; persistent Coolify storage must be mounted at `/app/data`.
+1 -1
View File
@@ -1 +1 @@
0.1.0+16
0.1.0+17
-57
View File
@@ -14,8 +14,6 @@ from user_store import save_api_call_log
logger = logging.getLogger(__name__)
APINEX_STT_URL = "https://api.apinex.bond/v1/tools/voice/stt"
APINEX_CHAT_URL = "https://api.apinex.bond/v1/chat/completions"
APINEX_TTS_URL = "https://api.apinex.bond/v1/tools/voice/tts"
TTS_NOT_CONFIGURED_MESSAGE = "Озвучка идей пока не настроена."
USER_AGENT = "Mozilla/5.0 (compatible; SocialPostWriterBot/1.0)"
POSTS_SYSTEM_PROMPT = """ВСЕ служебные сообщения пользователю — на русском языке. Исключение — дословно приводимый технический текст ошибки. Сам текст публикации пиши на языке, который пользователь явно запросил; при отсутствии явного языкового требования используй язык исходного запроса.
@@ -411,61 +409,6 @@ def _trim_history_posts(posts: list[str], char_limit: int = POST_HISTORY_CHAR_LI
return result
async def synthesize_idea_voice(text: str, user_id: int | None = None) -> bytes:
api_key = _api_key()
if not api_key:
raise RuntimeError("APINEX_API_KEY is missing")
model = _setting("APINEX_TTS_MODEL", "aura-2-thalia-en")
payload = {
"text": text[:1200],
"model": model,
"encoding": "mp3",
"container": "mp3",
"response_format": "audio",
}
request_for_log = {
"endpoint": "tools/voice/tts",
"model": model,
"text": payload["text"],
"encoding": "mp3",
"container": "mp3",
"response_format": "audio",
}
headers = {
"Authorization": f"Bearer {api_key}",
"Accept": "audio/mpeg, application/octet-stream",
"Content-Type": "application/json",
"User-Agent": USER_AGENT,
}
try:
timeout = aiohttp.ClientTimeout(total=120)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(APINEX_TTS_URL, json=payload, headers=headers) as response:
if response.status != 200:
raise RuntimeError(f"APInex TTS failed with HTTP {response.status}")
audio = await response.read()
response_meta = {
"content_type": response.headers.get("Content-Type", ""),
"audio_bytes": len(audio),
}
if not audio:
raise RuntimeError("APInex TTS returned empty audio")
except Exception as exc:
await _record_api_call(
stage="idea_voice_tts", model=model, prompt_name="idea_voice_tts",
system_prompt="", request=request_for_log,
status_code=_status_from_error(exc), error=f"{type(exc).__name__}: {exc}",
user_id=user_id,
)
raise
await _record_api_call(
stage="idea_voice_tts", model=model, prompt_name="idea_voice_tts",
system_prompt="", request=request_for_log, response=response_meta,
status_code=200, user_id=user_id,
)
return audio
async def generate_ideas(
request: str, tone_of_voice: str, history: list[str], idea_history: list[dict[str, str]], user_id: int | None = None
) -> dict[str, str]:
+2 -26
View File
@@ -12,9 +12,9 @@ from aiogram import Bot, Dispatcher, F, Router
from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.client.telegram import TEST
from aiogram.filters import CommandStart
from aiogram.types import BufferedInputFile, Message
from aiogram.types import Message
from apinex_client import APINEX_STT_URL, POST_HISTORY_CHAR_LIMIT, POST_HISTORY_LIMIT, classify_intent, create_tone_profile, extract_posts_from_images, extract_transcript, generate_ideas, generate_posts, synthesize_idea_voice, transcribe_audio
from apinex_client import APINEX_STT_URL, POST_HISTORY_CHAR_LIMIT, POST_HISTORY_LIMIT, classify_intent, create_tone_profile, extract_posts_from_images, extract_transcript, generate_ideas, generate_posts, transcribe_audio
from help_message import HELP_RICH_HTML, rich_to_plain
from user_store import (
add_post_samples,
@@ -164,13 +164,6 @@ async def help_command(message: Message) -> None:
await message.answer(rich_to_plain(HELP_RICH_HTML))
async def _send_idea_voice(message: Message, idea: dict[str, str], user_id: int) -> None:
text = f"{idea['title']}. {idea['angle']}. Зачем: {idea['why']}"
audio = await synthesize_idea_voice(text, user_id=user_id)
await message.answer_voice(BufferedInputFile(audio, filename="idea.mp3"))
await asyncio.to_thread(save_idea, user_id, idea)
async def _send_idea(message: Message, idea: dict[str, str], user_id: int) -> None:
await message.answer(f"{idea['title']}\n\n{idea['angle']}\n\nЗачем: {idea['why']}")
await asyncio.to_thread(save_idea, user_id, idea)
@@ -404,21 +397,6 @@ async def transcribe_voice(message: Message, bot: Bot) -> None:
await report_error(message, "обработка голосового и генерация", exc)
async def idea_voice_command(message: Message) -> None:
if message.from_user is None:
await message.answer("Не удалось определить пользователя.")
return
try:
async with typing_status(message.bot, message.chat.id):
tone = await asyncio.to_thread(get_tone_of_voice, message.from_user.id)
history = await asyncio.to_thread(get_recent_post_history_text, message.from_user.id, POST_HISTORY_LIMIT, POST_HISTORY_CHAR_LIMIT)
idea_history = await asyncio.to_thread(get_idea_history, message.from_user.id, 30)
idea = await generate_ideas("предложи идею для поста", tone, history, idea_history, user_id=message.from_user.id)
await _send_idea_voice(message, idea, message.from_user.id)
except Exception as exc:
await report_error(message, "озвучка идеи", exc)
@router.message(F.text & F.text.startswith("/"))
async def handle_command_text(message: Message) -> None:
command = (message.text or "").strip().split(maxsplit=1)[0].split("@", 1)[0].casefold()
@@ -430,8 +408,6 @@ async def handle_command_text(message: Message) -> None:
await clear_command(message)
elif command == "/idea":
await _classify_and_respond(message, "предложи идею для нового поста")
elif command == "/idea_voice":
await idea_voice_command(message)
else:
await message.answer("Неизвестная команда. Используй /help.")
-1
View File
@@ -16,7 +16,6 @@ HELP_RICH_HTML = """<h1>Помощь по боту</h1>
<h2>Попросить правку</h2>
<p>Ответь реплаем на сообщение с вариантом и укажи, что изменить. Например: «Сделай короче и добавь призыв». Я учту оригинальный текст и верну три новых варианта отдельными сообщениями.</p>
<h2>Команды</h2>
<p>Команда <code>/idea_voice</code> отправляет одну идею голосовым сообщением.</p>
<p>Быстрые slash-команды только на английском: <code>/help</code> — эта инструкция; <code>/start</code> — краткое описание бота; <code>/clear</code> — полностью обнулить твои данные бота, кроме технических API-логов; <code>/idea</code> — предложить одну идею для нового поста. Можно попросить идею обычными словами: «предложи идею для поста», «о чём написать?», «накидай тему».</p>
"""
+2 -2
View File
@@ -49,7 +49,7 @@ class HelpRoutingTests(unittest.TestCase):
self.assertIn("elif command == \"/start\"", source)
self.assertIn("elif command == \"/clear\"", source)
self.assertIn("elif command == \"/idea\"", source)
self.assertIn("elif command == \"/idea_voice\"", source)
self.assertNotIn("idea_voice", source)
def test_help_rich_message_contains_usage_scenarios(self):
from help_message import HELP_RICH_HTML, rich_to_plain
@@ -57,7 +57,7 @@ class HelpRoutingTests(unittest.TestCase):
text = rich_to_plain(HELP_RICH_HTML)
self.assertIn("/clear", text)
self.assertIn("/idea", text)
self.assertIn("/idea_voice", text)
self.assertNotIn("/idea_voice", text)
for term in ("пост", "голосовое", "скриншот", "реплаем", "/help"):
self.assertIn(term, text)
+8 -33
View File
@@ -10,9 +10,10 @@ import user_store
class FakeResponse:
def __init__(self, status, payload):
def __init__(self, status, payload, headers=None):
self.status = status
self.payload = payload
self.headers = headers or {}
async def __aenter__(self):
return self
@@ -23,6 +24,12 @@ class FakeResponse:
async def json(self, content_type=None):
return self.payload
async def text(self):
if isinstance(self.payload, bytes):
return self.payload.decode("utf-8", errors="replace")
return str(self.payload)
class FakeClientSession:
def __init__(self, response, **kwargs):
@@ -195,38 +202,6 @@ class ApinexTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(captured["system_prompt"], apinex_client.TONE_PROFILE_SYSTEM_PROMPT)
self.assertEqual(tone, "Разговорный стиль, короткие абзацы")
async def test_idea_voice_sends_one_audio_message_and_persists_idea_after_send(self):
message = SimpleNamespace(answer_voice=AsyncMock())
idea = {"title": "Заголовок", "angle": "Угол", "why": "Причина"}
async def run_in_thread(function, *args):
return function(*args)
with (
patch.object(bot, "synthesize_idea_voice", new=AsyncMock(return_value=b"OggS-audio")),
patch.object(bot, "save_idea") as save_idea,
patch.object(bot.asyncio, "to_thread", side_effect=run_in_thread),
):
await bot._send_idea_voice(message, idea, 91)
message.answer_voice.assert_awaited_once()
voice_file = message.answer_voice.await_args.args[0]
self.assertEqual(voice_file.filename, "idea.mp3")
save_idea.assert_called_once_with(91, idea)
async def test_synthesize_idea_voice_returns_mp3_audio(self):
def fake_run(command, **kwargs):
output_path = command[-1] if command[0].endswith("lame") else command[command.index("-w") + 1]
with open(output_path, "wb") as output_file:
output_file.write(b"ID3" + b"x" * 1200)
with (
patch.object(apinex_client.shutil, "which", side_effect=["/usr/bin/espeak-ng", "/usr/bin/lame"]),
patch.object(apinex_client.subprocess, "run", side_effect=fake_run),
):
audio = await apinex_client.synthesize_idea_voice("Идея для поста о кофе")
self.assertTrue(audio.startswith(b"ID3"))
self.assertGreater(len(audio), 1000)
async def test_idea_generation_returns_topics_only_and_uses_history(self):
captured = {}