diff --git a/Dev_Docs/Pending_Features/2026-05-30_0013_голосовая_адаптация_telegram_бота.md b/Dev_Docs/Pending_Features/2026-05-30_0013_голосовая_адаптация_telegram_бота.md new file mode 100644 index 0000000..e1c8c9b --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-05-30_0013_голосовая_адаптация_telegram_бота.md @@ -0,0 +1,19 @@ +# Голосовая адаптация ответов Telegram-бота + +## Краткое описание +Добавлены персональные настройки голосовых ответов и адаптации текста перед озвучкой. Если голосовые ответы включены, сервис перед TTS может отдельно прогонять финальный текст через OpenAI-модель и отправлять более короткую голосовую версию в исходный чат, личный чат пользователя и общий чат `@shine_writing`, если эти чаты доступны и отличаются. + +## Что проверить +- Команды `/voice_on`, `/voice_off`, `/voice_status` для конкретного пользователя. +- Команды `/voice_rewrite_on`, `/voice_rewrite_off`, `/voice_rewrite_status` для конкретного пользователя. +- Команда `/status` показывает очередь, голосовые ответы и адаптацию текста перед озвучкой. +- При включённых голосовых ответах после задачи приходит текстовый ответ и voice-ответ. +- При включённой адаптации voice-ответ короче и без длинных технических строк. +- При задаче из личного чата voice дополнительно появляется в общем чате `@shine_writing`. +- При задаче из общего чата voice дополнительно появляется в личном чате пользователя, если сервис уже знает его личный chat_id. + +## Ожидаемый результат +Текстовый ответ остаётся полным. Голосовая версия приходит отдельно, звучит короче и естественнее, а персональные настройки одного пользователя не меняют поведение других пользователей. + +## Статус +pending diff --git a/SHiNE-agent-bot-coder/AGENT.md b/SHiNE-agent-bot-coder/AGENT.md index 80a77f5..0b16f35 100644 --- a/SHiNE-agent-bot-coder/AGENT.md +++ b/SHiNE-agent-bot-coder/AGENT.md @@ -33,11 +33,13 @@ - После успешной обработки задачи из личного чата Айдара сервис должен отправить публичный итоговый отчёт в группу `@shine_writing`: первым сообщением исходный запрос, вторым сообщением-ответом итоговый ответ Codex. Промежуточные статусы в группу не дублировать. - Для приватных voice/audio-запросов в публичном отчёте первым сообщением отправлять исходный Telegram voice/audio-файл с подписью, где указан распознанный текст. В пользовательском тексте отчёта не показывать Telegram `file_id`. - Озвучивание финальных ответов настраивается персонально для каждого Telegram-пользователя командами `/voice_on`, `/voice_off`, `/voice_status`. -- Если озвучивание включено, после полного текстового финального ответа сервис дополнительно отправляет voice-файл с синтезированной речью через OpenAI TTS. Промежуточные статусы и публичный отчёт в `@shine_writing` не озвучивать. +- Адаптация текста перед озвучкой настраивается персонально командами `/voice_rewrite_on`, `/voice_rewrite_off`, `/voice_rewrite_status`. Если она включена, сервис перед TTS вызывает дешёвую текстовую модель OpenAI и делает короткую голосовую версию без длинных хэшей, путей, команд и технического шума. +- Если озвучивание включено, после полного текстового финального ответа сервис дополнительно отправляет voice-файл с синтезированной речью через OpenAI TTS. Voice отправляется в исходный чат, а также в известный личный чат пользователя и в общий чат `@shine_writing`, если они отличаются и доступны. Промежуточные статусы не озвучивать. +- Команда `/status` должна показывать состояние очереди и персональные состояния голосовых функций: включены ли voice-ответы и адаптация текста перед озвучкой. ## Правила голосовой версии ответа - Текстовый финальный ответ должен оставаться полноценным: в нём можно указывать команды, пути, хэши коммитов, номера версий, результаты проверок и другие технические детали. -- Голосовую версию финального ответа нужно делать короче и проще для восприятия на слух. +- Голосовую версию финального ответа нужно делать короче и проще для восприятия на слух. Основной механизм — персонально включаемая адаптация текста через дополнительный OpenAI-вызов перед TTS. - В голосовой версии не зачитывать длинные хэши коммитов, токены, file_id, длинные команды, полные пути и другие строки, которые человек всё равно не сможет надёжно запомнить на слух. - Для commit/push в голосовой версии достаточно сказать краткий итог: что коммит сделан, что именно изменено, проверки прошли без ошибок, push выполнен, рабочее дерево чистое. - Если пользователю нужны точные команды, хэши или подробности, они должны оставаться в текстовом ответе. diff --git a/SHiNE-agent-bot-coder/py_bot_service.py b/SHiNE-agent-bot-coder/py_bot_service.py index 4964067..35062e0 100644 --- a/SHiNE-agent-bot-coder/py_bot_service.py +++ b/SHiNE-agent-bot-coder/py_bot_service.py @@ -322,6 +322,10 @@ class BotConfig: self.openai_tts_response_format = env.get("OPENAI_TTS_RESPONSE_FORMAT", "opus") self.openai_tts_timeout_seconds = int(env.get("OPENAI_TTS_TIMEOUT_SECONDS", "180")) self.openai_tts_chunk_chars = max(500, int(env.get("OPENAI_TTS_CHUNK_CHARS", "3500"))) + self.openai_voice_rewrite_model = env.get("OPENAI_VOICE_REWRITE_MODEL", "gpt-4.1-nano") + self.openai_voice_rewrite_timeout_seconds = int(env.get("OPENAI_VOICE_REWRITE_TIMEOUT_SECONDS", "90")) + self.openai_voice_rewrite_max_input_chars = max(1000, int(env.get("OPENAI_VOICE_REWRITE_MAX_INPUT_CHARS", "12000"))) + self.openai_voice_rewrite_max_output_tokens = max(200, int(env.get("OPENAI_VOICE_REWRITE_MAX_OUTPUT_TOKENS", "900"))) self.codex_bin = Path(env.get( "CODEX_BIN", "/home/ai/.cache/JetBrains/IntelliJIdea2026.1/aia/codex/bin/codex-x86_64-unknown-linux-musl" @@ -608,6 +612,8 @@ class ShinePyBotService: settings[uname] = user_settings if not isinstance(user_settings.get("voice_replies_enabled"), bool): user_settings["voice_replies_enabled"] = False + if not isinstance(user_settings.get("voice_rewrite_enabled"), bool): + user_settings["voice_rewrite_enabled"] = True return user_settings def _voice_replies_enabled(self, username: str) -> bool: @@ -617,6 +623,33 @@ class ShinePyBotService: self._user_settings(username)["voice_replies_enabled"] = enabled self._persist_state() + def _voice_rewrite_enabled(self, username: str) -> bool: + return bool(self._user_settings(username).get("voice_rewrite_enabled")) + + def _set_voice_rewrite_enabled(self, username: str, enabled: bool) -> None: + self._user_settings(username)["voice_rewrite_enabled"] = enabled + self._persist_state() + + def _remember_private_chat(self, username: str, chat_id: int) -> None: + uname = normalize_username(username) + if not uname: + return + private_chats = self.state.get("private_chat_ids") + if not isinstance(private_chats, dict): + private_chats = {} + self.state["private_chat_ids"] = private_chats + if private_chats.get(uname) == chat_id: + return + private_chats[uname] = chat_id + self._persist_state() + + def _private_chat_id_for_user(self, username: str) -> int | None: + private_chats = self.state.get("private_chat_ids") + if not isinstance(private_chats, dict): + return None + chat_id = private_chats.get(normalize_username(username)) + return self._resolve_chat_id(chat_id) if isinstance(chat_id, int) else None + def _append_history(self, history_path: Path, event_type: str, payload: dict[str, Any]) -> None: row = {"ts": now_iso(), "type": event_type} row.update(payload) @@ -754,6 +787,8 @@ class ShinePyBotService: return self._ensure_user_session(actor_username) + if is_private: + self._remember_private_chat(actor_username, chat_id) history_path = self._current_history_file_for_user(actor_username) if self._is_allowed_player(actor_username): self._send_player_welcome_once(chat_id, message_id, actor_username) @@ -906,7 +941,7 @@ class ShinePyBotService: self._safe_send(chat_id, self._help_text(is_owner=is_owner), reply_to=message_id) return if command == "/status": - self._safe_send(chat_id, self._status_text(), reply_to=message_id) + self._safe_send(chat_id, self._status_text(username), reply_to=message_id) return if command == "/queue": self._safe_send(chat_id, self._queue_text(), reply_to=message_id) @@ -923,7 +958,26 @@ class ShinePyBotService: return if command == "/voice_status": status = "включено" if self._voice_replies_enabled(username) else "выключено" - self._safe_send(chat_id, f"Озвучивание финальных ответов: {status}.", reply_to=message_id) + rewrite_status = "включена" if self._voice_rewrite_enabled(username) else "выключена" + self._safe_send( + chat_id, + f"Озвучивание финальных ответов: {status}.\nАдаптация текста перед озвучкой: {rewrite_status}.", + reply_to=message_id, + ) + return + if command == "/voice_rewrite_on": + self._set_voice_rewrite_enabled(username, True) + self._append_history_event("voice_rewrite_enabled", {"username": normalize_username(username)}, username=username) + self._safe_send(chat_id, "Адаптация текста перед озвучкой включена для вашего пользователя.", reply_to=message_id) + return + if command == "/voice_rewrite_off": + self._set_voice_rewrite_enabled(username, False) + self._append_history_event("voice_rewrite_disabled", {"username": normalize_username(username)}, username=username) + self._safe_send(chat_id, "Адаптация текста перед озвучкой выключена для вашего пользователя.", reply_to=message_id) + return + if command == "/voice_rewrite_status": + status = "включена" if self._voice_rewrite_enabled(username) else "выключена" + self._safe_send(chat_id, f"Адаптация текста перед озвучкой: {status}.", reply_to=message_id) return if command == "/new": archived = self._rotate_history("command_new", username) @@ -981,26 +1035,35 @@ class ShinePyBotService: "/new — архивировать историю и начать новую", "/voice_on — включить озвучивание финальных ответов", "/voice_off — выключить озвучивание финальных ответов", - "/voice_status — показать состояние озвучивания", + "/voice_rewrite_on — включить адаптацию текста перед озвучкой", + "/voice_rewrite_off — выключить адаптацию текста перед озвучкой", + "/voice_status — показать состояние голосовых функций", "/help — эта справка", ] if is_owner: lines.insert(-1, "/restart_service — перезапустить сервис через systemd") return "\n".join(lines) - def _status_text(self) -> str: + def _status_text(self, username: str) -> str: with self.queue_lock: active = next((j for j in self.queue if j.get("status") == "active"), None) pending = sum(1 for j in self.queue if j.get("status") == "pending") + voice_status = "включено" if self._voice_replies_enabled(username) else "выключено" + rewrite_status = "включена" if self._voice_rewrite_enabled(username) else "выключена" + settings_text = ( + f"Голосовые ответы: {voice_status}\n" + f"Адаптация текста перед озвучкой: {rewrite_status}" + ) if not active: - return f"Статус: активной задачи нет.\nВ очереди pending: {pending}" + return f"Статус: активной задачи нет.\nВ очереди pending: {pending}\n{settings_text}" elapsed = int(time.time() - (self.active_job_started_at or time.time())) return ( f"Статус: активная задача #{active.get('num', '?')}\n" f"Тип: {active.get('type', 'text')}\n" f"Попытка: {int(active.get('attempts', 0)) + 1}/{self.cfg.max_retries}\n" f"Выполняется: {elapsed}с\n" - f"Pending: {pending}" + f"Pending: {pending}\n" + f"{settings_text}" ) def _queue_text(self) -> str: @@ -1097,12 +1160,12 @@ class ShinePyBotService: answer = self._run_codex(prompt, chat_id, message_id, job_id, job_num) for chunk in split_long_text(answer): self._safe_send(chat_id, chunk, reply_to=message_id) - if self._voice_replies_enabled(job.get("username") or ""): - self._send_voice_reply_for_answer(chat_id, message_id, job_num, answer, history_path, job_id) - self._safe_send(chat_id, f"Готово #{job_num}.", reply_to=message_id) self._append_history(history_path, "codex_response", {"jobId": job_id, "text": answer}) - self._mark_job_done(job_id) self._send_private_job_public_report(job, answer) + if self._voice_replies_enabled(job.get("username") or ""): + self._send_voice_reply_for_answer(job, answer, history_path, job_id) + self._safe_send(chat_id, f"Готово #{job_num}.", reply_to=message_id) + self._mark_job_done(job_id) except Exception as e: if self.stop_current_job: self._append_history(history_path, "job_stopped", {"jobId": job_id, "reason": str(e)}) @@ -1524,28 +1587,85 @@ class ShinePyBotService: threading.Thread(target=restart, name="shine-py-bot-self-restart", daemon=True).start() + def _voice_reply_targets(self, job: dict[str, Any]) -> list[tuple[int | str, int | None, str]]: + chat_id = int(job["chat_id"]) + message_id = int(job["message_id"]) + targets: list[tuple[int | str, int | None, str]] = [(chat_id, message_id, "source")] + username = job.get("username") or "" + + private_chat_id = self._private_chat_id_for_user(username) + if private_chat_id is not None and private_chat_id != self._resolve_chat_id(chat_id): + targets.append((private_chat_id, None, "private")) + + report_chat_id = self._public_report_chat_id() + if report_chat_id is not None: + resolved_report_chat_id = self._resolve_chat_id(report_chat_id) if isinstance(report_chat_id, int) else report_chat_id + resolved_current_chat_id: int | str = self._resolve_chat_id(chat_id) + if resolved_report_chat_id != resolved_current_chat_id: + targets.append((report_chat_id, None, "public")) + + deduped: list[tuple[int | str, int | None, str]] = [] + seen: set[str] = set() + for target_chat_id, target_reply_to, label in targets: + resolved: int | str = self._resolve_chat_id(target_chat_id) if isinstance(target_chat_id, int) else target_chat_id + key = str(resolved) + if key in seen: + continue + seen.add(key) + deduped.append((target_chat_id, target_reply_to, label)) + return deduped + def _send_voice_reply_for_answer( self, - chat_id: int, - message_id: int, - job_num: Any, + job: dict[str, Any], answer: str, history_path: Path, job_id: str, ) -> None: + chat_id = int(job["chat_id"]) + message_id = int(job["message_id"]) + job_num = job.get("num", "?") + username = job.get("username") or "" if not self.cfg.openai_api_key: note = "не настроен ключ OpenAI для озвучивания." self._append_history(history_path, "voice_reply_failed", {"jobId": job_id, "jobNum": job_num, "error": note}) self._safe_send(chat_id, f"Озвучивание включено, но {note}", reply_to=message_id) return - chunks = split_text_for_tts(answer, self.cfg.openai_tts_chunk_chars) + voice_text = answer + rewrite_enabled = self._voice_rewrite_enabled(username) + if rewrite_enabled: + try: + voice_text = self._openai_rewrite_text_for_voice(answer) + self._append_history(history_path, "voice_rewrite_done", { + "jobId": job_id, + "jobNum": job_num, + "model": self.cfg.openai_voice_rewrite_model, + "sourceChars": len(answer or ""), + "resultChars": len(voice_text or ""), + }) + except VoiceReplyError as e: + self._append_history(history_path, "voice_rewrite_failed", { + "jobId": job_id, + "jobNum": job_num, + "model": self.cfg.openai_voice_rewrite_model, + "error": str(e), + }) + self._safe_send( + chat_id, + f"Не удалось адаптировать ответ #{job_num} для озвучки, озвучиваю обычный текст: {e}", + reply_to=message_id, + ) + voice_text = answer + + chunks = split_text_for_tts(voice_text, self.cfg.openai_tts_chunk_chars) if not chunks: return sent_count = 0 total = len(chunks) - print(f"[py-bot] tts start job={str(job_id)[:8]} chunks={total}", flush=True) + targets = self._voice_reply_targets(job) + print(f"[py-bot] tts start job={str(job_id)[:8]} chunks={total} targets={len(targets)} rewrite={rewrite_enabled}", flush=True) for index, chunk in enumerate(chunks, start=1): try: audio = self._openai_tts(chunk) @@ -1562,27 +1682,99 @@ class ShinePyBotService: caption = f"Озвучка ответа #{job_num}" if total > 1: caption += f", часть {index}/{total}" - message_sent = self._safe_send_voice_upload( - chat_id, - audio, - f"shine-answer-{job_num}-{index}.ogg", - caption=caption, - reply_to=message_id, - ) - if message_sent is None: - self._append_history(history_path, "voice_reply_failed", { - "jobId": job_id, - "jobNum": job_num, - "part": index, - "parts": total, - "error": "Telegram не принял voice-файл озвучки.", - }) - self._safe_send(chat_id, f"Озвучка ответа #{job_num} создана, но Telegram не принял voice-файл.", reply_to=message_id) - return - sent_count += 1 - self._append_history(history_path, "voice_reply_sent", {"jobId": job_id, "jobNum": job_num, "parts": sent_count}) + for target_chat_id, target_reply_to, target_label in targets: + message_sent = self._safe_send_voice_upload( + target_chat_id, + audio, + f"shine-answer-{job_num}-{index}.ogg", + caption=caption, + reply_to=target_reply_to, + ) + if message_sent is None: + self._append_history(history_path, "voice_reply_failed", { + "jobId": job_id, + "jobNum": job_num, + "part": index, + "parts": total, + "target": target_label, + "error": "Telegram не принял voice-файл озвучки.", + }) + if target_label == "source": + self._safe_send(chat_id, f"Озвучка ответа #{job_num} создана, но Telegram не принял voice-файл.", reply_to=message_id) + continue + sent_count += 1 + self._append_history(history_path, "voice_reply_sent", { + "jobId": job_id, + "jobNum": job_num, + "parts": total, + "messages": sent_count, + "targets": len(targets), + "rewriteEnabled": rewrite_enabled, + }) print(f"[py-bot] tts done job={str(job_id)[:8]} sent={sent_count}", flush=True) + def _openai_rewrite_text_for_voice(self, text: str) -> str: + source = (text or "").strip() + if not source: + return "" + if len(source) > self.cfg.openai_voice_rewrite_max_input_chars: + source = source[:self.cfg.openai_voice_rewrite_max_input_chars].rstrip() + "\n\n...[текстовый ответ был длиннее и обрезан для голосовой версии]" + payload = { + "model": self.cfg.openai_voice_rewrite_model, + "messages": [ + { + "role": "system", + "content": ( + "Ты готовишь короткую русскую голосовую версию финального ответа технического агента. " + "Сохрани итог, важные предупреждения и действия. Убери длинные пути, хэши, команды, номера версий, " + "JSON, списки файлов и другие строки, которые плохо воспринимаются на слух. " + "Не добавляй новых фактов. Пиши естественно, кратко, без markdown." + ), + }, + { + "role": "user", + "content": f"Переделай этот финальный текст в вариант для озвучки:\n\n{source}", + }, + ], + "temperature": 0.2, + "max_tokens": self.cfg.openai_voice_rewrite_max_output_tokens, + } + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = request.Request("https://api.openai.com/v1/chat/completions", method="POST", data=data) + req.add_header("Authorization", f"Bearer {self.cfg.openai_api_key}") + req.add_header("Content-Type", "application/json") + try: + with request.urlopen(req, timeout=self.cfg.openai_voice_rewrite_timeout_seconds) as resp: + raw = resp.read().decode("utf-8", errors="replace") + except TimeoutError as e: + raise VoiceReplyError( + f"OpenAI не успел адаптировать текст за {self.cfg.openai_voice_rewrite_timeout_seconds} секунд." + ) from e + except error.HTTPError as e: + detail = e.read().decode("utf-8", errors="replace") + if e.code == 401: + message = "OpenAI отклонил ключ API для адаптации текста." + elif e.code == 429: + message = "OpenAI временно ограничил адаптацию текста из-за лимита запросов." + elif e.code >= 500: + message = "OpenAI временно не смог адаптировать текст." + else: + message = f"OpenAI вернул ошибку HTTP {e.code} при адаптации текста." + if detail: + message = f"{message} Детали: {detail[:500]}" + raise VoiceReplyError(message) from e + except error.URLError as e: + raise VoiceReplyError(f"не удалось отправить текст в OpenAI для адаптации из-за сетевой ошибки: {e.reason}") from e + + try: + body = json.loads(raw) + content = (((body.get("choices") or [{}])[0].get("message") or {}).get("content") or "").strip() + except Exception as e: + raise VoiceReplyError("OpenAI вернул неразборчивый ответ при адаптации текста.") from e + if not content: + raise VoiceReplyError("OpenAI вернул пустой текст адаптации.") + return content + def _openai_tts(self, text: str) -> bytes: payload = { "model": self.cfg.openai_tts_model, diff --git a/VERSION.properties b/VERSION.properties index b83b887..78bb681 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.97 -server.version=1.2.91 +client.version=1.2.98 +server.version=1.2.92