feat: remove /idea_voice, revert to text-only ideas
This commit is contained in:
1 parent
7b76bef889
commit
2941cb2a24
13 files changed
+152
-124
No files matched your search
@@ -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 передаются только ограниченные последние записи; системные промпты не меняются пользовательскими данными. Идеи не превращаются в готовые посты автоматически.
|
||||
@@ -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 не входят в этот план.
|
||||
@@ -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] Служебные ответы по-русски, язык текстов постов определяется запросом пользователя
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
@@ -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>
|
||||
"""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
Reference in new issue
Block a user