diff --git a/handlers/legal_advisor.py b/handlers/legal_advisor.py index d0ee115..04c83d8 100644 --- a/handlers/legal_advisor.py +++ b/handlers/legal_advisor.py @@ -130,21 +130,21 @@ async def cmd_legal_advisor(message: Message, state: FSMContext, bot: Bot): if len(parts) > 1 and parts[0].casefold() in ("/юрист", "/jurist", "/вопрос"): inline_question = parts[1].strip() if len(inline_question) > 3: - status_msg = await message.answer("⏳ Анализирую нормы законодательства РФ...", parse_mode='HTML') + status_msg = await message.answer("⏳ Изучаю документы, сметы и решения ОСС дома № 37 в блокноте NotebookLM...", parse_mode='HTML') result = await legal_engine.query(prompt=inline_question, role=role) await status_msg.delete() if result.get("success"): - header = "⚖️ Юридический ответ:" + header = "⚖️ Юридический ответ по документам дома № 37:" await send_split_message(message, header, result["text"]) await state.set_state(LegalQuestionState.waiting_for_question) else: - await message.answer(f"❌ Ошибка: {result.get('text')}", parse_mode='HTML') + await message.answer(f"{result.get('text')}", parse_mode='HTML') return text = ( - "⚖️ Юридический помощник (ЖК РФ)\n\n" + "⚖️ Юридический помощник (Дом № 37)\n\n" "Напишите ваш вопрос (текстом или отправьте голосовое сообщение).\n" - "Ответ формируется по нормам законодательства РФ и судебной практике." + "Ответ формируется строго по сметам, договорам, протоколам ОСС и нормам ЖК РФ в блокноте NotebookLM." ) await message.answer(text, parse_mode='HTML') await state.set_state(LegalQuestionState.waiting_for_question) @@ -161,9 +161,9 @@ async def cb_legal_advisor(callback: CallbackQuery, state: FSMContext): return text = ( - "⚖️ Юридический помощник (ЖК РФ)\n\n" + "⚖️ Юридический помощник (Дом № 37)\n\n" "Напишите ваш вопрос (текстом или отправьте голосовое сообщение).\n" - "Ответ формируется по нормам законодательства РФ и судебной практике." + "Ответ формируется строго по сметам, договорам, протоколам ОСС и нормам ЖК РФ в блокноте NotebookLM." ) await callback.message.answer(text, parse_mode='HTML') await state.set_state(LegalQuestionState.waiting_for_question) @@ -239,7 +239,7 @@ async def process_text_legal_question(message: Message, state: FSMContext, bot: question = message.text status_msg = await message.answer( - "⏳ Анализирую нормы законодательства РФ...", + "⏳ Изучаю документы, сметы и решения ОСС дома № 37 в блокноте NotebookLM...", parse_mode='HTML' ) @@ -247,7 +247,7 @@ async def process_text_legal_question(message: Message, state: FSMContext, bot: await status_msg.delete() if not result.get("success"): - await message.answer(f"❌ Ошибка: {result.get('text')}", parse_mode='HTML') + await message.answer(f"{result.get('text')}", parse_mode='HTML') return # Защита от спама: если вопрос задан в групповом чате @@ -256,7 +256,7 @@ async def process_text_legal_question(message: Message, state: FSMContext, bot: try: await bot.send_message( chat_id=user_id, - text=f"⚖️ Юридический ответ:\n\n{format_telegram_html(result['text'])}", + text=f"⚖️ Юридический ответ по документам дома № 37:\n\n{format_telegram_html(result['text'])}", parse_mode='HTML', reply_markup=get_legal_nav_keyboard() ) @@ -271,7 +271,7 @@ async def process_text_legal_question(message: Message, state: FSMContext, bot: ) else: # В личном диалоге отвечаем напрямую - header = "⚖️ Юридический ответ:" + header = "⚖️ Юридический ответ по документам дома № 37:" await send_split_message(message, header, result["text"]) # Оставляем состояние активным, чтобы жилец мог сразу задать следующий вопрос без клика @@ -293,7 +293,7 @@ async def process_voice_legal_question(message: Message, state: FSMContext, bot: return status_msg = await message.answer( - "🎙 Анализирую голосовое обращение по нормам законодательства...", + "🎙 Распознаю аудио и сверяю с документами дома № 37 в NotebookLM...", parse_mode='HTML' ) @@ -304,16 +304,21 @@ async def process_voice_legal_question(message: Message, state: FSMContext, bot: voice_file_path = str(TEMP_VOICE_DIR / f"voice_{user_id}_{int(message.date.timestamp())}.ogg") await bot.download_file(file_info.file_path, destination=voice_file_path) - # Передаем напрямую в мультимодальный Gemini + # Передаем в legal_engine (Gemini транскрибирует аудио в текст, затем NotebookLM ищет по базе дома) result = await legal_engine.query(prompt="", role=role, audio_path=voice_file_path) await status_msg.delete() if not result.get("success"): - await message.answer(f"❌ Ошибка: {result.get('text')}", parse_mode='HTML') + await message.answer(f"{result.get('text')}", parse_mode='HTML') return - header = "⚖️ Юридический ответ:" + transcribed = result.get("transcribed_question") + if transcribed: + header = f"⚖️ Юридический ответ по документам дома № 37:\n(Вопрос: «{format_telegram_html(transcribed)}»)" + else: + header = "⚖️ Юридический ответ по документам дома № 37:" + await send_split_message(message, header, result["text"]) await state.set_state(LegalQuestionState.waiting_for_question) diff --git a/services/ai_legal_engine.py b/services/ai_legal_engine.py index 54a7a53..0c3379b 100644 --- a/services/ai_legal_engine.py +++ b/services/ai_legal_engine.py @@ -99,8 +99,36 @@ class AILegalEngine: data = await resp.json() return data["candidates"][0]["content"]["parts"][0]["text"] + async def transcribe_audio(self, audio_path: str) -> str: + """Транскрибирует аудиосообщение жильца в текст через Gemini Multimodal Audio (Voice-to-Text).""" + if not self.api_key or not audio_path or not os.path.exists(audio_path): + return "" + + prompt = ( + "Дословно расшифруй аудиосообщение на русском языке. " + "Не добавляй пояснений, не отвечай на вопрос и не искажай слова жильца. " + "Выведи ТОЛЬКО расшифрованный текст." + ) + system_instruction = "Ты — высокоточный модуль транскрибации речи (Speech-to-Text). Верни только произнесенный текст." + + for model_to_use in [self.model_name, "gemini-flash-latest", "gemini-2.5-flash"]: + try: + transcription = await self._query_rest_api( + prompt=prompt, + system_instruction=system_instruction, + model=model_to_use, + audio_path=audio_path + ) + if transcription and transcription.strip(): + return transcription.strip() + except Exception as e: + log.warning(f"Voice transcription attempt failed on {model_to_use}: {e}") + await asyncio.sleep(0.5) + + return "" + async def query_notebooklm(self, prompt: str) -> dict: - """Прямой запрос к блокноту NotebookLM «ЛенКом37 Юрист».""" + """Прямой запрос к блокноту NotebookLM «ЛенКом37 Юрист» (RAG по документам дома № 37).""" import shutil notebook_id = os.getenv("NOTEBOOKLM_ID", "59c41b23-5858-4a74-bf60-4ce17e0ca710") candidates = [ @@ -112,7 +140,6 @@ class AILegalEngine: nb_bin = None for b in candidates: if b and os.path.exists(b) and os.access(b, os.X_OK): - # Проверим, не сломан ли шебанг try: with open(b, "rb") as f: first_line = f.readline() @@ -144,110 +171,100 @@ class AILegalEngine: env=env ) try: - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=180.0) + # Увеличенный лимит ожидания (до 300 сек для глубокого RAG-анализа смет и решений) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=300.0) except asyncio.TimeoutError: try: proc.kill() except: pass - return {"success": False, "error": "Таймаут обращения к NotebookLM (более 180 сек)", "source": "notebooklm"} + return { + "success": False, + "error": "Таймаут обработки запроса к блокноту документов дома (более 300 сек)", + "source": "notebooklm" + } out_str = stdout.decode('utf-8', errors='ignore').strip() err_str = stderr.decode('utf-8', errors='ignore').strip() if proc.returncode == 0 and out_str and "Authentication Error" not in out_str and "Not logged in" not in out_str: - lines = [l for l in out_str.splitlines() if not l.startswith("Continuing conversation") and not l.startswith("Resumed conversation")] + lines = [ + l for l in out_str.splitlines() + if not l.startswith("Continuing conversation") + and not l.startswith("Resumed conversation") + ] cleaned = "\n".join(lines).strip() if cleaned.startswith("Answer:"): cleaned = cleaned[len("Answer:"):].strip() return {"success": True, "text": cleaned, "source": "notebooklm"} else: - log.warning(f"NotebookLM non-zero or auth err: code={proc.returncode}, out={out_str[:200]}, err={err_str[:200]}") + log.warning(f"NotebookLM non-zero or auth err: code={proc.returncode}, out={out_str[:250]}, err={err_str[:250]}") return {"success": False, "error": err_str or out_str, "source": "notebooklm"} except Exception as e: log.error(f"NotebookLM exec exception: {e}") return {"success": False, "error": str(e), "source": "notebooklm"} async def query(self, prompt: str, role: str = "resident", audio_path: str = None) -> dict: - """Основной метод запроса: NotebookLM (первый безусловный приоритет) -> Gemini (fallback/аудио).""" + """ + Канонический метод запроса юридического помощника МКД «Домовой 2.0»: + - Все ответы формируются СТРОГО на основе документов дома № 37 из блокнота NotebookLM. + - Никаких общих абстрактных ответов или галлюцинаций Gemini! + - При отправке аудиосообщения: Gemini выполняет ИСКЛЮЧИТЕЛЬНО распознавание голоса в текст (STT), + после чего текст вопроса направляется в NotebookLM. + """ start_time = datetime.now() + transcribed_text = None - # 1. Текстовые вопросы ВСЕХ пользователей идут строго в блокнот «Разбор ответов строго по ЖК РФ»! - if not audio_path and prompt: - nlm_res = await self.query_notebooklm(prompt) - if nlm_res.get("success"): - latency = (datetime.now() - start_time).total_seconds() + # 1. Если передано аудиосообщение — транскрибируем его через Gemini Multimodal в текст + if audio_path: + log.info(f"Received audio voice message ({audio_path}), transcribing via Gemini STT...") + transcribed_text = await self.transcribe_audio(audio_path) + if not transcribed_text: return { - "success": True, - "text": nlm_res["text"], - "answer": nlm_res["text"], + "success": False, + "text": "❌ Не удалось распознать голосовое сообщение. Пожалуйста, напишите ваш вопрос текстом.", "role": role, - "latency_sec": round(latency, 2), - "model": "NotebookLM: Разбор ответов строго по ЖК РФ" + "latency_sec": round((datetime.now() - start_time).total_seconds(), 2) } - else: - log.warning(f"NotebookLM query failed: {nlm_res.get('error')}. Falling back to Gemini.") + log.info(f"Audio voice successfully transcribed: '{transcribed_text[:120]}...'") + prompt = transcribed_text - # 2. Резервный контур через Gemini API (для аудиосообщений либо если блокнот временно недоступен) - if not self.api_key: + if not prompt or not prompt.strip(): return { "success": False, - "text": "ИИ-модуль временно недоступен. Проверьте подключение.", + "text": "❌ Вопрос пустой. Пожалуйста, сформулируйте юридический вопрос.", "role": role, "latency_sec": 0.0 } - system_instruction = ( - "Ты — профессиональный юридический консультант по жилищному праву РФ (ЖК РФ, ГК РФ, защита прав потребителей в сфере ЖКХ).\n" - "ЖЕСТКИЕ ПРАВИЛА ОФОРМЛЕНИЯ:\n" - "1. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО писать любые самопрезентации, самоназвания, титулы, приветствия или регалии (никаких 'Я — аналитик', 'Главный стратег', 'Юридический щит', 'Здравствуйте', 'Я помогу вам').\n" - "2. Ответ начинать СТРОГО сразу по существу вопроса.\n" - "3. Полный минимализм: только факты, правовые нормы, статьи законов и пошаговый алгоритм действий. Без рекламной шелухи и упоминания технологий/моделей.\n" - f"{self.get_role_instructions(role)}" - ) + # 2. Обращение СТРОГО к блокноту NotebookLM (RAG по сметам, договорам и протоколам ОСС) + nlm_res = await self.query_notebooklm(prompt) + latency = round((datetime.now() - start_time).total_seconds(), 2) - # Каскад моделей при перегрузке или временной недоступности (503/429) - models_to_try = [ - self.model_name, - "gemini-flash-latest", - "gemini-3-flash-preview" - ] - # Убираем дубликаты с сохранением порядка - seen = set() - models_cascade = [m for m in models_to_try if not (m in seen or seen.add(m))] - - last_err = None - for model_to_use in models_cascade: - try: - reply_text = await self._query_rest_api( - prompt=prompt, - system_instruction=system_instruction, - model=model_to_use, - audio_path=audio_path - ) - - latency = (datetime.now() - start_time).total_seconds() - - return { - "success": True, - "text": reply_text.strip(), - "answer": reply_text.strip(), - "role": role, - "latency_sec": round(latency, 2), - "model": model_to_use - } - - except Exception as e: - last_err = e - log.warning(f"Model {model_to_use} failed ({e}), trying next fallback...") - await asyncio.sleep(0.5) - - latency = (datetime.now() - start_time).total_seconds() - log.error(f"All Gemini fallback models failed: {last_err}") - return { - "success": False, - "text": f"Шлюз ИИ временно перегружен. Пожалуйста, повторите вопрос через несколько секунд.", - "role": role, - "latency_sec": round(latency, 2) - } + if nlm_res.get("success"): + return { + "success": True, + "text": nlm_res["text"], + "answer": nlm_res["text"], + "role": role, + "latency_sec": latency, + "model": "NotebookLM: ЛенКом37 Юрист (Документы дома № 37)", + "transcribed_question": transcribed_text + } + else: + err_msg = nlm_res.get("error", "Неизвестная ошибка") + log.error(f"NotebookLM legal query failed: {err_msg}") + # Никакого fallback на обычный Gemini! Выдаем честное уведомление об обновлении базы + return { + "success": False, + "text": ( + "❌ База документов дома № 37 временно обновляет сессию.\n\n" + "Юридический помощник не выдает выдуманных общих ответов нейросетей, " + "а обращается строго к протоколам ОСС, сметам и договорам нашего дома в NotebookLM. " + "Пожалуйста, повторите вопрос через минуту." + ), + "role": role, + "latency_sec": latency, + "error_detail": err_msg + } # Глобальный синглтон юридического ядра для роутеров бота legal_engine = AILegalEngine() @@ -255,7 +272,7 @@ legal_engine = AILegalEngine() if __name__ == "__main__": async def test(): engine = AILegalEngine() - res = await engine.query("Какие сроки ответа прокуратуры по закону 59-ФЗ?", role="resident") + res = await engine.query("Сравни за что мы голосуем на общих собраниях - за обрезку или глубокую обрезку деревьев во дворе. А в отчетах - как списывают?") print("Success:", res["success"]) print("Latency:", res["latency_sec"]) print("Model:", res.get("model"))