Compare commits

..

No commits in common. "1abadd9081264d9e0005ba7ab7a4c41c9fe39a13" and "fca335423efa7f085ab89ae305d3eec35fc4e8cf" have entirely different histories.

2 changed files with 90 additions and 112 deletions

View file

@ -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", "/вопрос"): if len(parts) > 1 and parts[0].casefold() in ("/юрист", "/jurist", "/вопрос"):
inline_question = parts[1].strip() inline_question = parts[1].strip()
if len(inline_question) > 3: if len(inline_question) > 3:
status_msg = await message.answer("⏳ <i>Изучаю документы, сметы и решения ОСС дома № 37 в блокноте NotebookLM...</i>", parse_mode='HTML') status_msg = await message.answer("⏳ <i>Анализирую нормы законодательства РФ...</i>", parse_mode='HTML')
result = await legal_engine.query(prompt=inline_question, role=role) result = await legal_engine.query(prompt=inline_question, role=role)
await status_msg.delete() await status_msg.delete()
if result.get("success"): if result.get("success"):
header = "⚖️ <b>Юридический ответ по документам дома № 37:</b>" header = "⚖️ <b>Юридический ответ:</b>"
await send_split_message(message, header, result["text"]) await send_split_message(message, header, result["text"])
await state.set_state(LegalQuestionState.waiting_for_question) await state.set_state(LegalQuestionState.waiting_for_question)
else: else:
await message.answer(f"{result.get('text')}", parse_mode='HTML') await message.answer(f"❌ <b>Ошибка:</b> {result.get('text')}", parse_mode='HTML')
return return
text = ( text = (
"⚖️ <b>Юридический помощник (Дом № 37)</b>\n\n" "⚖️ <b>Юридический помощник (ЖК РФ)</b>\n\n"
"Напишите ваш вопрос (текстом или отправьте голосовое сообщение).\n" "Напишите ваш вопрос (текстом или отправьте голосовое сообщение).\n"
"<i>Ответ формируется строго по сметам, договорам, протоколам ОСС и нормам ЖК РФ в блокноте NotebookLM.</i>" "<i>Ответ формируется по нормам законодательства РФ и судебной практике.</i>"
) )
await message.answer(text, parse_mode='HTML') await message.answer(text, parse_mode='HTML')
await state.set_state(LegalQuestionState.waiting_for_question) await state.set_state(LegalQuestionState.waiting_for_question)
@ -161,9 +161,9 @@ async def cb_legal_advisor(callback: CallbackQuery, state: FSMContext):
return return
text = ( text = (
"⚖️ <b>Юридический помощник (Дом № 37)</b>\n\n" "⚖️ <b>Юридический помощник (ЖК РФ)</b>\n\n"
"Напишите ваш вопрос (текстом или отправьте голосовое сообщение).\n" "Напишите ваш вопрос (текстом или отправьте голосовое сообщение).\n"
"<i>Ответ формируется строго по сметам, договорам, протоколам ОСС и нормам ЖК РФ в блокноте NotebookLM.</i>" "<i>Ответ формируется по нормам законодательства РФ и судебной практике.</i>"
) )
await callback.message.answer(text, parse_mode='HTML') await callback.message.answer(text, parse_mode='HTML')
await state.set_state(LegalQuestionState.waiting_for_question) 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 question = message.text
status_msg = await message.answer( status_msg = await message.answer(
"⏳ <i>Изучаю документы, сметы и решения ОСС дома № 37 в блокноте NotebookLM...</i>", "⏳ <i>Анализирую нормы законодательства РФ...</i>",
parse_mode='HTML' parse_mode='HTML'
) )
@ -247,7 +247,7 @@ async def process_text_legal_question(message: Message, state: FSMContext, bot:
await status_msg.delete() await status_msg.delete()
if not result.get("success"): if not result.get("success"):
await message.answer(f"{result.get('text')}", parse_mode='HTML') await message.answer(f"❌ <b>Ошибка:</b> {result.get('text')}", parse_mode='HTML')
return return
# Защита от спама: если вопрос задан в групповом чате # Защита от спама: если вопрос задан в групповом чате
@ -256,7 +256,7 @@ async def process_text_legal_question(message: Message, state: FSMContext, bot:
try: try:
await bot.send_message( await bot.send_message(
chat_id=user_id, chat_id=user_id,
text=f"⚖️ <b>Юридический ответ по документам дома № 37:</b>\n\n{format_telegram_html(result['text'])}", text=f"⚖️ <b>Юридический ответ:</b>\n\n{format_telegram_html(result['text'])}",
parse_mode='HTML', parse_mode='HTML',
reply_markup=get_legal_nav_keyboard() reply_markup=get_legal_nav_keyboard()
) )
@ -271,7 +271,7 @@ async def process_text_legal_question(message: Message, state: FSMContext, bot:
) )
else: else:
# В личном диалоге отвечаем напрямую # В личном диалоге отвечаем напрямую
header = "⚖️ <b>Юридический ответ по документам дома № 37:</b>" header = "⚖️ <b>Юридический ответ:</b>"
await send_split_message(message, header, result["text"]) await send_split_message(message, header, result["text"])
# Оставляем состояние активным, чтобы жилец мог сразу задать следующий вопрос без клика # Оставляем состояние активным, чтобы жилец мог сразу задать следующий вопрос без клика
@ -293,7 +293,7 @@ async def process_voice_legal_question(message: Message, state: FSMContext, bot:
return return
status_msg = await message.answer( status_msg = await message.answer(
"🎙 <i>Распознаю аудио и сверяю с документами дома № 37 в NotebookLM...</i>", "🎙 <i>Анализирую голосовое обращение по нормам законодательства...</i>",
parse_mode='HTML' parse_mode='HTML'
) )
@ -304,21 +304,16 @@ 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") 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) await bot.download_file(file_info.file_path, destination=voice_file_path)
# Передаем в legal_engine (Gemini транскрибирует аудио в текст, затем NotebookLM ищет по базе дома) # Передаем напрямую в мультимодальный Gemini
result = await legal_engine.query(prompt="", role=role, audio_path=voice_file_path) result = await legal_engine.query(prompt="", role=role, audio_path=voice_file_path)
await status_msg.delete() await status_msg.delete()
if not result.get("success"): if not result.get("success"):
await message.answer(f"{result.get('text')}", parse_mode='HTML') await message.answer(f"❌ <b>Ошибка:</b> {result.get('text')}", parse_mode='HTML')
return return
transcribed = result.get("transcribed_question") header = "⚖️ <b>Юридический ответ:</b>"
if transcribed:
header = f"⚖️ <b>Юридический ответ по документам дома № 37:</b>\n<i>(Вопрос: «{format_telegram_html(transcribed)}»)</i>"
else:
header = "⚖️ <b>Юридический ответ по документам дома № 37:</b>"
await send_split_message(message, header, result["text"]) await send_split_message(message, header, result["text"])
await state.set_state(LegalQuestionState.waiting_for_question) await state.set_state(LegalQuestionState.waiting_for_question)

View file

@ -99,36 +99,8 @@ class AILegalEngine:
data = await resp.json() data = await resp.json()
return data["candidates"][0]["content"]["parts"][0]["text"] 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: async def query_notebooklm(self, prompt: str) -> dict:
"""Прямой запрос к блокноту NotebookLM «ЛенКом37 Юрист» (RAG по документам дома № 37).""" """Прямой запрос к блокноту NotebookLM «ЛенКом37 Юрист»."""
import shutil import shutil
notebook_id = os.getenv("NOTEBOOKLM_ID", "59c41b23-5858-4a74-bf60-4ce17e0ca710") notebook_id = os.getenv("NOTEBOOKLM_ID", "59c41b23-5858-4a74-bf60-4ce17e0ca710")
candidates = [ candidates = [
@ -140,6 +112,7 @@ class AILegalEngine:
nb_bin = None nb_bin = None
for b in candidates: for b in candidates:
if b and os.path.exists(b) and os.access(b, os.X_OK): if b and os.path.exists(b) and os.access(b, os.X_OK):
# Проверим, не сломан ли шебанг
try: try:
with open(b, "rb") as f: with open(b, "rb") as f:
first_line = f.readline() first_line = f.readline()
@ -171,100 +144,110 @@ class AILegalEngine:
env=env env=env
) )
try: try:
# Увеличенный лимит ожидания (до 300 сек для глубокого RAG-анализа смет и решений) stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=90.0)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=300.0)
except asyncio.TimeoutError: except asyncio.TimeoutError:
try: proc.kill() try: proc.kill()
except: pass except: pass
return { return {"success": False, "error": "Таймаут обращения к NotebookLM (более 90 сек)", "source": "notebooklm"}
"success": False,
"error": "Таймаут обработки запроса к блокноту документов дома (более 300 сек)",
"source": "notebooklm"
}
out_str = stdout.decode('utf-8', errors='ignore').strip() out_str = stdout.decode('utf-8', errors='ignore').strip()
err_str = stderr.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: if proc.returncode == 0 and out_str and "Authentication Error" not in out_str and "Not logged in" not in out_str:
lines = [ lines = [l for l in out_str.splitlines() if not l.startswith("Continuing conversation") and not l.startswith("Resumed conversation")]
l for l in out_str.splitlines()
if not l.startswith("Continuing conversation")
and not l.startswith("Resumed conversation")
]
cleaned = "\n".join(lines).strip() cleaned = "\n".join(lines).strip()
if cleaned.startswith("Answer:"): if cleaned.startswith("Answer:"):
cleaned = cleaned[len("Answer:"):].strip() cleaned = cleaned[len("Answer:"):].strip()
return {"success": True, "text": cleaned, "source": "notebooklm"} return {"success": True, "text": cleaned, "source": "notebooklm"}
else: else:
log.warning(f"NotebookLM non-zero or auth err: code={proc.returncode}, out={out_str[:250]}, err={err_str[:250]}") log.warning(f"NotebookLM non-zero or auth err: code={proc.returncode}, out={out_str[:200]}, err={err_str[:200]}")
return {"success": False, "error": err_str or out_str, "source": "notebooklm"} return {"success": False, "error": err_str or out_str, "source": "notebooklm"}
except Exception as e: except Exception as e:
log.error(f"NotebookLM exec exception: {e}") log.error(f"NotebookLM exec exception: {e}")
return {"success": False, "error": str(e), "source": "notebooklm"} return {"success": False, "error": str(e), "source": "notebooklm"}
async def query(self, prompt: str, role: str = "resident", audio_path: str = None) -> dict: 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() start_time = datetime.now()
transcribed_text = None
# 1. Если передано аудиосообщение — транскрибируем его через Gemini Multimodal в текст # 1. Текстовые вопросы ВСЕХ пользователей идут строго в блокнот «Разбор ответов строго по ЖК РФ»!
if audio_path: if not audio_path and prompt:
log.info(f"Received audio voice message ({audio_path}), transcribing via Gemini STT...") nlm_res = await self.query_notebooklm(prompt)
transcribed_text = await self.transcribe_audio(audio_path) if nlm_res.get("success"):
if not transcribed_text: latency = (datetime.now() - start_time).total_seconds()
return { return {
"success": False, "success": True,
"text": "❌ <b>Не удалось распознать голосовое сообщение.</b> Пожалуйста, напишите ваш вопрос текстом.", "text": nlm_res["text"],
"answer": nlm_res["text"],
"role": role, "role": role,
"latency_sec": round((datetime.now() - start_time).total_seconds(), 2) "latency_sec": round(latency, 2),
"model": "NotebookLM: Разбор ответов строго по ЖК РФ"
} }
log.info(f"Audio voice successfully transcribed: '{transcribed_text[:120]}...'") else:
prompt = transcribed_text log.warning(f"NotebookLM query failed: {nlm_res.get('error')}. Falling back to Gemini.")
if not prompt or not prompt.strip(): # 2. Резервный контур через Gemini API (для аудиосообщений либо если блокнот временно недоступен)
if not self.api_key:
return { return {
"success": False, "success": False,
"text": "❌ Вопрос пустой. Пожалуйста, сформулируйте юридический вопрос.", "text": "ИИ-модуль временно недоступен. Проверьте подключение.",
"role": role, "role": role,
"latency_sec": 0.0 "latency_sec": 0.0
} }
# 2. Обращение СТРОГО к блокноту NotebookLM (RAG по сметам, договорам и протоколам ОСС) system_instruction = (
nlm_res = await self.query_notebooklm(prompt) "Ты — профессиональный юридический консультант по жилищному праву РФ (ЖК РФ, ГК РФ, защита прав потребителей в сфере ЖКХ).\n"
latency = round((datetime.now() - start_time).total_seconds(), 2) "ЖЕСТКИЕ ПРАВИЛА ОФОРМЛЕНИЯ:\n"
"1. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО писать любые самопрезентации, самоназвания, титулы, приветствия или регалии (никаких 'Я — аналитик', 'Главный стратег', 'Юридический щит', 'Здравствуйте', 'Я помогу вам').\n"
"2. Ответ начинать СТРОГО сразу по существу вопроса.\n"
"3. Полный минимализм: только факты, правовые нормы, статьи законов и пошаговый алгоритм действий. Без рекламной шелухи и упоминания технологий/моделей.\n"
f"{self.get_role_instructions(role)}"
)
if nlm_res.get("success"): # Каскад моделей при перегрузке или временной недоступности (503/429)
return { models_to_try = [
"success": True, self.model_name,
"text": nlm_res["text"], "gemini-flash-latest",
"answer": nlm_res["text"], "gemini-3-flash-preview"
"role": role, ]
"latency_sec": latency, # Убираем дубликаты с сохранением порядка
"model": "NotebookLM: ЛенКом37 Юрист (Документы дома № 37)", seen = set()
"transcribed_question": transcribed_text models_cascade = [m for m in models_to_try if not (m in seen or seen.add(m))]
}
else: last_err = None
err_msg = nlm_res.get("error", "Неизвестная ошибка") for model_to_use in models_cascade:
log.error(f"NotebookLM legal query failed: {err_msg}") try:
# Никакого fallback на обычный Gemini! Выдаем честное уведомление об обновлении базы reply_text = await self._query_rest_api(
return { prompt=prompt,
"success": False, system_instruction=system_instruction,
"text": ( model=model_to_use,
"❌ <b>База документов дома № 37 временно обновляет сессию.</b>\n\n" audio_path=audio_path
"Юридический помощник не выдает выдуманных общих ответов нейросетей, " )
"а обращается строго к протоколам ОСС, сметам и договорам нашего дома в NotebookLM. "
"Пожалуйста, повторите вопрос через минуту." latency = (datetime.now() - start_time).total_seconds()
),
"role": role, return {
"latency_sec": latency, "success": True,
"error_detail": err_msg "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)
}
# Глобальный синглтон юридического ядра для роутеров бота # Глобальный синглтон юридического ядра для роутеров бота
legal_engine = AILegalEngine() legal_engine = AILegalEngine()
@ -272,7 +255,7 @@ legal_engine = AILegalEngine()
if __name__ == "__main__": if __name__ == "__main__":
async def test(): async def test():
engine = AILegalEngine() engine = AILegalEngine()
res = await engine.query("Сравни за что мы голосуем на общих собраниях - за обрезку или глубокую обрезку деревьев во дворе. А в отчетах - как списывают?") res = await engine.query("Какие сроки ответа прокуратуры по закону 59-ФЗ?", role="resident")
print("Success:", res["success"]) print("Success:", res["success"])
print("Latency:", res["latency_sec"]) print("Latency:", res["latency_sec"])
print("Model:", res.get("model")) print("Model:", res.get("model"))