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"))