fix(voice): true 3-stage voice processing: 1. raw recognized text in chat -> 2. edit message into crystal structure (essence, tasks, ideas) -> 3. separate report on Obsidian save and executed actions
This commit is contained in:
parent
6d53dac3fb
commit
5bfe439a0f
1 changed files with 125 additions and 64 deletions
|
|
@ -281,35 +281,35 @@ async def transcribe_audio(audio_path: str) -> str:
|
|||
os.remove(tmp_wav)
|
||||
return ""
|
||||
|
||||
async def execute_antigravity_agent(user_text: str) -> str:
|
||||
"""Обработка команды и мыслей агентом Майа с систематизацией, Битрикс24 и правом исполнения bash-команд"""
|
||||
system_instruction = (
|
||||
"Ты — Майа (Maya / Antigravity Lead Agent), суперинтеллектуальный помощник Александра с полными правами инженера.\n"
|
||||
"Александр обращается к тебе голосом в Телеграмме (на ходу, на велосипеде, за рулем, спонтанный поток мыслей с паузами, оговорками и повторами).\n\n"
|
||||
"ТВОЯ ГЛАВНАЯ ЦЕЛЬ — ПРЕВРАТИТЬ СПОНТАННЫЙ ПОТОК РЕЧИ В КРИСТАЛЬНО ЧИСТУЮ СТРУКТУРУ И ВЫПОЛНИТЬ ТРЕБУЕМЫЕ ДЕЙСТВИЯ.\n\n"
|
||||
"ОБЯЗАТЕЛЬНЫЙ ШАБЛОН ТВОЕГО ТЕКСТОВОГО ОТВЕТА (СТРОГО СОБЛЮДАЙ ЭТУ СТРУКТУРУ В КАЖДОМ ОТВЕТЕ!):\n\n"
|
||||
"🎯 <b>Суть:</b>\n"
|
||||
"[Очищенная от слов-паразитов, междометий и сумбура суть сказанного в 1-3 емких предложениях]\n\n"
|
||||
"📋 <b>Задачи к выполнению (Action Items):</b>\n"
|
||||
"• [ ] [Конкретная задача 1]\n"
|
||||
"• [ ] [Конкретная задача 2]\n"
|
||||
"(Если задач к исполнению не было — напиши: «Конкретных задач не поставлено — зафиксировано как идея/мысли»)\n\n"
|
||||
"💡 <b>Идеи в копилку / Бэклог:</b>\n"
|
||||
"• [Инсайт, гипотеза или идея на будущее]\n"
|
||||
"(Если идей не было — напиши: «—»)\n\n"
|
||||
"🗄️ <b>Действия и фиксация:</b>\n"
|
||||
"• [ОБЯЗАТЕЛЬНО: Вызови save_to_obsidian и укажи: ✅ Сохранено в Obsidian: раздел/подкаталог/файл.md]\n"
|
||||
"• [Если Александр просил проверить или сделать что-то — вызови query_bitrix24 или execute_bash_command и напиши конкретный результат выполнения!]\n\n"
|
||||
"ПРАВИЛА:\n"
|
||||
"1. ЛЮБЫЕ мысли, идеи, планы, рассуждения ОБЯЗАТЕЛЬНО сохраняй в Obsidian через tool save_to_obsidian(section, subfolder, filename, title, content, tags).\n"
|
||||
" - section: 'HomeLab', 'Work' или 'Свалка'.\n"
|
||||
" - subfolder: 'AI_Swarm', 'Infrastructure', 'Bicycle_Ebike', 'Housing_LKM37', 'Visma', 'Bitrix24', 'General'.\n"
|
||||
" - filename: говорящее имя на латинице (например: 'ebike_controller_upgrade.md').\n"
|
||||
" - content: структурированный текст заметки.\n"
|
||||
" КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО писать в чат только фразу «я сохранила в файл»! Ты ОБЯЗАНА полностью вывести блоки 🎯 Суть, 📋 Задачи, 💡 Идеи в сам ответ, чтобы Александр видел структуру прямо перед глазами!\n"
|
||||
"2. Если Александр попросил действие (Битрикс24, свободное место, контейнеры, сервера) — вызови инструмент (query_bitrix24 / execute_bash_command) и выведи результат в блок «🗄️ Действия и фиксация».\n"
|
||||
"3. В САМОМ КОНЦЕ ответа ОБЯЗАТЕЛЬНО добавь тег <voice>...</voice> с короткой (1-2 предложения), теплой голосовой выжимкой для Шаде без Markdown и эмодзи."
|
||||
)
|
||||
async def structure_thoughts(user_text: str) -> str:
|
||||
"""Этап 2: Превращение сумбурного потока мыслей в кристальную структуру с метаданными для Obsidian"""
|
||||
prompt = f"""
|
||||
Ты — Майа, личный аналитик и инженер Александра.
|
||||
Александр наговорил голосовое сообщение на ходу. В речи могут быть паузы, оговорки, повторы, слова-паразиты, сумбур.
|
||||
|
||||
Твоя задача — кристально структурировать надиктованное по строгой схеме:
|
||||
|
||||
🎯 <b>Суть:</b>
|
||||
[Главное зерно мысли в 1-2 емких, четких предложениях, полностью очищенных от мусорных слов]
|
||||
|
||||
📋 <b>Задачи к выполнению:</b>
|
||||
* [ ] [Конкретная задача 1]
|
||||
* [ ] [Конкретная задача 2]
|
||||
(Если задач нет — напиши: «Конкретных задач не поставлено — зафиксировано как идея/мысли»)
|
||||
|
||||
💡 <b>Идеи в копилку / Бэклог:</b>
|
||||
* [Инсайт, гипотеза или идея на перспективу]
|
||||
(Если идей нет — напиши: «—»)
|
||||
|
||||
ОБЯЗАТЕЛЬНО добавь в конце метаданные для сохранения в Obsidian:
|
||||
<obsidian section="HomeLab|Work|Свалка" subfolder="AI_Swarm|Infrastructure|Bicycle_Ebike|Housing_LKM37|Visma|Bitrix24|General" filename="имя_файла_на_латинице.md" title="Понятный заголовок заметки" />
|
||||
|
||||
И добавь тег с короткой теплой выжимкой для синтеза речи (1-2 предложения без markdown):
|
||||
<voice>Текст выжимки от первого лица для озвучки</voice>
|
||||
|
||||
Сырой текст речи Александра:
|
||||
«{user_text}»
|
||||
"""
|
||||
|
||||
models_to_try = [
|
||||
'gemini-2.5-flash',
|
||||
|
|
@ -317,30 +317,69 @@ async def execute_antigravity_agent(user_text: str) -> str:
|
|||
'gemini-2.5-pro',
|
||||
'gemini-3.5-flash'
|
||||
]
|
||||
last_error = None
|
||||
|
||||
def _sync_run_agent(m_name: str, u_text: str) -> str:
|
||||
for model_name in models_to_try:
|
||||
try:
|
||||
logger.info(f"🧠 [Maya Structuring] Запуск с моделью: {model_name}")
|
||||
def _sync_gen(m_name, p):
|
||||
model = genai.GenerativeModel(m_name)
|
||||
resp = model.generate_content(p)
|
||||
return resp.text.strip() if resp and resp.text else ""
|
||||
|
||||
res = await asyncio.to_thread(_sync_gen, model_name, prompt)
|
||||
if res:
|
||||
return res
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ [Maya Structuring] Сбой на модели {model_name}: {e}")
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
return (
|
||||
f"🎯 <b>Суть:</b>\n{user_text}\n\n"
|
||||
f"📋 <b>Задачи к выполнению:</b>\n* [ ] Разобрать надиктованное голосовое сообщение\n\n"
|
||||
f"💡 <b>Идеи в копилку / Бэклог:</b>\n—\n\n"
|
||||
f"<obsidian section=\"HomeLab\" subfolder=\"General\" filename=\"note_{int(datetime.now().timestamp())}.md\" title=\"Голосовая мысль\" />\n"
|
||||
f"<voice>Я зафиксировала твою мысль и сохранила её в Obsidian.</voice>"
|
||||
)
|
||||
|
||||
async def execute_voice_commands_if_any(user_text: str) -> str:
|
||||
"""Этап 3: Проверка и выполнение реальных инженерных команд (Битрикс24, bash, серверы)"""
|
||||
system_instruction = (
|
||||
"Ты — Майа, системный инженер и исполнитель команд Александра.\n"
|
||||
"Проанализируй текст Александра. Если он содержит ЯВНЫЕ команды к исполнению прямо сейчас "
|
||||
"(например: проверить задачи в Битрикс24, посмотреть статус сервера, проверить диск/память/докер, выполнить команду):\n"
|
||||
"1. Немедленно вызови соответствующий инструмент (query_bitrix24 или execute_bash_command).\n"
|
||||
"2. Дай четкий, емкий инженерный результат выполнения.\n"
|
||||
"Если же текст Александра — это ПРОСТО мысли, рассуждения, идеи или планы на будущее (без прямого приказа выполнить что-то прямо сейчас) — "
|
||||
"верни СТРОГО ОДНО СЛОВО: НЕТ_КОМАНД"
|
||||
)
|
||||
|
||||
models_to_try = [
|
||||
'gemini-2.5-flash',
|
||||
'gemini-flash-latest',
|
||||
'gemini-2.5-pro'
|
||||
]
|
||||
|
||||
def _sync_cmd(m_name, u_txt):
|
||||
model = genai.GenerativeModel(
|
||||
model_name=m_name,
|
||||
system_instruction=system_instruction,
|
||||
tools=[query_bitrix24, execute_bash_command, save_to_obsidian]
|
||||
tools=[query_bitrix24, execute_bash_command]
|
||||
)
|
||||
chat = model.start_chat(enable_automatic_function_calling=True)
|
||||
resp = chat.send_message(u_text)
|
||||
resp = chat.send_message(u_txt)
|
||||
return resp.text.strip() if resp and resp.text else ""
|
||||
|
||||
for model_name in models_to_try:
|
||||
try:
|
||||
logger.info(f"🚀 [Maya Agent] Запуск с моделью: {model_name}")
|
||||
response_text = await asyncio.to_thread(_sync_run_agent, model_name, user_text)
|
||||
if response_text:
|
||||
return response_text
|
||||
logger.info(f"⚡️ [Maya Commands] Проверка наличия команд ({model_name})...")
|
||||
res = await asyncio.to_thread(_sync_cmd, model_name, user_text)
|
||||
if res:
|
||||
return res
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ [Maya Agent] Сбой на модели {model_name}: {e}")
|
||||
last_error = e
|
||||
logger.warning(f"⚠️ [Maya Commands] Ошибка {model_name}: {e}")
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
return f"❌ Ошибка исполнения Maya Agent: {last_error}"
|
||||
return "НЕТ_КОМАНД"
|
||||
|
||||
@router.message(F.voice | F.video_note | F.audio | F.document)
|
||||
async def handle_voice_message(message: Message, bot: Bot):
|
||||
|
|
@ -390,13 +429,13 @@ async def handle_voice_message(message: Message, bot: Bot):
|
|||
size_mb = round(file_size / (1024 * 1024), 1)
|
||||
await message.reply(
|
||||
f"⚠️ <b>Файл слишком большой ({size_mb} МБ).</b>\n\n"
|
||||
"Telegram Bot API жестко ограничивает скачивание ботами файлов размером до 20 МБ.\n"
|
||||
"💡 <b>Решение:</b> Сконвертируй запись диктофона в AAC/M4A/MP3 с более низким битрейтом (например, 64 kbps mono) или надиктуй голосовыми сообщениями прямо в чат!",
|
||||
"Telegram Bot API ограничивает скачивание ботами файлов размером до 20 МБ.\n"
|
||||
"💡 <b>Решение:</b> Сконвертируй запись диктофона в AAC/M4A/MP3 (64 kbps mono) или надиктуй голосовыми в чат!",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
return
|
||||
|
||||
status_msg = await message.reply("🎙 <i>Слушаю и выполняю команду...</i>", parse_mode="HTML")
|
||||
status_msg = await message.reply("🎙 <i>Слушаю аудиозапись...</i>", parse_mode="HTML")
|
||||
await bot.send_chat_action(message.chat.id, "typing")
|
||||
|
||||
try:
|
||||
|
|
@ -404,7 +443,7 @@ async def handle_voice_message(message: Message, bot: Bot):
|
|||
file_info = await bot.get_file(file_id)
|
||||
await bot.download_file(file_info.file_path, local_audio_path)
|
||||
|
||||
# 1. Прямая расшифровка через локальный Whisper M4 (с фоллбэком на Gemini)
|
||||
# 1. Расшифровка через Gemini Audio API (с нормализацией ffmpeg)
|
||||
transcribed_text = await transcribe_audio(local_audio_path)
|
||||
|
||||
if os.path.exists(local_audio_path):
|
||||
|
|
@ -413,34 +452,56 @@ async def handle_voice_message(message: Message, bot: Bot):
|
|||
if transcribed_text:
|
||||
log_voice_to_db(message.from_user.id, message.from_user.username, msg_type, int(duration), transcribed_text)
|
||||
|
||||
safe_transcribed = html.escape(transcribed_text)
|
||||
# ===== ЭТАП 1: Показываем сырой текст как есть (без эффектов) =====
|
||||
safe_raw = html.escape(transcribed_text)
|
||||
await status_msg.edit_text(
|
||||
f"🗣 <b>Вы сказали:</b>\n<tg-spoiler>{safe_transcribed}</tg-spoiler>\n\n"
|
||||
f"🧠 <i>Майа структурирует мысли и выполняет действия...</i>",
|
||||
f"🗣 <b>Услышано:</b>\n«<i>{safe_raw}</i>»\n\n"
|
||||
f"🧠 <i>Майа структурирует мысли и вникает в суть...</i>",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await bot.send_chat_action(message.chat.id, "record_voice")
|
||||
await bot.send_chat_action(message.chat.id, "typing")
|
||||
|
||||
# 2. Исполнение Maya Lead Agent (Битрикс24, bash, анализ)
|
||||
agent_response = await execute_antigravity_agent(transcribed_text)
|
||||
# ===== ЭТАП 2: Структурирование мыслей через модель =====
|
||||
structured_raw = await structure_thoughts(transcribed_text)
|
||||
|
||||
# 3. Подготовка текста для отправки (вырезаем тег <voice>)
|
||||
clean_text = re.sub(r"<voice>.*?</voice>", "", agent_response, flags=re.DOTALL).strip()
|
||||
formatted_text = (
|
||||
f"🗣 <b>Вы сказали:</b>\n<tg-spoiler>{safe_transcribed}</tg-spoiler>\n\n"
|
||||
f"🎯 <b>Майа // Структурированный отклик:</b>\n\n"
|
||||
f"{clean_text}"
|
||||
# Извлекаем теги метаданных и озвучки
|
||||
m_obs = re.search(
|
||||
r"<obsidian\s+section=[\"\'](.*?)[\"\']\s+subfolder=[\"\'](.*?)[\"\']\s+filename=[\"\'](.*?)[\"\']\s+title=[\"\'](.*?)[\"\']\s*/>",
|
||||
structured_raw
|
||||
)
|
||||
|
||||
if len(formatted_text) > 4000:
|
||||
await status_msg.edit_text(formatted_text[:4000], parse_mode="HTML")
|
||||
await message.reply(formatted_text[4000:], parse_mode="HTML")
|
||||
else:
|
||||
await status_msg.edit_text(formatted_text, parse_mode="HTML")
|
||||
obs_section = m_obs.group(1) if m_obs else "HomeLab"
|
||||
obs_subfolder = m_obs.group(2) if m_obs else "General"
|
||||
obs_filename = m_obs.group(3) if m_obs else f"note_{int(datetime.now().timestamp())}.md"
|
||||
obs_title = m_obs.group(4) if m_obs else "Голосовая мысль"
|
||||
|
||||
# 4. Синтез и отправка голосового ответа
|
||||
# Очищаем структурированный текст для отображения в Telegram
|
||||
clean_structure = re.sub(r"<obsidian.*?>", "", structured_raw, flags=re.DOTALL)
|
||||
clean_structure = re.sub(r"<voice>.*?</voice>", "", clean_structure, flags=re.DOTALL).strip()
|
||||
|
||||
# Редактируем статусное сообщение: сырой текст заменяется на красивую структуру!
|
||||
formatted_structure = f"🎯 <b>Майа // Структурированный отклик:</b>\n\n{clean_structure}"
|
||||
if len(formatted_structure) > 4000:
|
||||
await status_msg.edit_text(formatted_structure[:4000], parse_mode="HTML")
|
||||
else:
|
||||
await status_msg.edit_text(formatted_structure, parse_mode="HTML")
|
||||
|
||||
# ===== ЭТАП 3: Физические действия (Obsidian + Команды) отдельным сообщением! =====
|
||||
# 1. Запись в Obsidian
|
||||
obsidian_res = save_to_obsidian(obs_section, obs_subfolder, obs_filename, obs_title, clean_structure)
|
||||
|
||||
# 2. Выполнение команд (если были)
|
||||
commands_result = await execute_voice_commands_if_any(transcribed_text)
|
||||
|
||||
# 3. Отправка отчета о действиях ОТДЕЛЬНЫМ сообщением
|
||||
action_report = f"🗄️ <b>Действия и фиксация:</b>\n• {obsidian_res}"
|
||||
if commands_result and commands_result.strip() != "НЕТ_КОМАНД":
|
||||
action_report += f"\n\n⚙️ <b>Результат выполнения:</b>\n{commands_result.strip()}"
|
||||
|
||||
await message.reply(action_report, parse_mode="HTML")
|
||||
|
||||
# ===== 4. Голосовой отклик голосом Шаде =====
|
||||
try:
|
||||
voice_bytes = await text_to_speech_bytes(agent_response)
|
||||
voice_bytes = await text_to_speech_bytes(structured_raw)
|
||||
if voice_bytes:
|
||||
voice_file = BufferedInputFile(voice_bytes, filename="reply_voice.mp3")
|
||||
await message.reply_voice(voice=voice_file, caption="🎙 Голосовой отклик Майи")
|
||||
|
|
|
|||
Loading…
Reference in a new issue