Compare commits
2 commits
fca335423e
...
1abadd9081
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1abadd9081 | ||
|
|
36c5e51566 |
2 changed files with 112 additions and 90 deletions
|
|
@ -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>Анализирую нормы законодательства РФ...</i>", parse_mode='HTML')
|
status_msg = await message.answer("⏳ <i>Изучаю документы, сметы и решения ОСС дома № 37 в блокноте NotebookLM...</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>Юридический ответ:</b>"
|
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)
|
||||||
else:
|
else:
|
||||||
await message.answer(f"❌ <b>Ошибка:</b> {result.get('text')}", parse_mode='HTML')
|
await message.answer(f"{result.get('text')}", parse_mode='HTML')
|
||||||
return
|
return
|
||||||
|
|
||||||
text = (
|
text = (
|
||||||
"⚖️ <b>Юридический помощник (ЖК РФ)</b>\n\n"
|
"⚖️ <b>Юридический помощник (Дом № 37)</b>\n\n"
|
||||||
"Напишите ваш вопрос (текстом или отправьте голосовое сообщение).\n"
|
"Напишите ваш вопрос (текстом или отправьте голосовое сообщение).\n"
|
||||||
"<i>Ответ формируется по нормам законодательства РФ и судебной практике.</i>"
|
"<i>Ответ формируется строго по сметам, договорам, протоколам ОСС и нормам ЖК РФ в блокноте NotebookLM.</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>Юридический помощник (ЖК РФ)</b>\n\n"
|
"⚖️ <b>Юридический помощник (Дом № 37)</b>\n\n"
|
||||||
"Напишите ваш вопрос (текстом или отправьте голосовое сообщение).\n"
|
"Напишите ваш вопрос (текстом или отправьте голосовое сообщение).\n"
|
||||||
"<i>Ответ формируется по нормам законодательства РФ и судебной практике.</i>"
|
"<i>Ответ формируется строго по сметам, договорам, протоколам ОСС и нормам ЖК РФ в блокноте NotebookLM.</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>Анализирую нормы законодательства РФ...</i>",
|
"⏳ <i>Изучаю документы, сметы и решения ОСС дома № 37 в блокноте NotebookLM...</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"❌ <b>Ошибка:</b> {result.get('text')}", parse_mode='HTML')
|
await message.answer(f"{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>Юридический ответ:</b>\n\n{format_telegram_html(result['text'])}",
|
text=f"⚖️ <b>Юридический ответ по документам дома № 37:</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>Юридический ответ:</b>"
|
header = "⚖️ <b>Юридический ответ по документам дома № 37:</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>Анализирую голосовое обращение по нормам законодательства...</i>",
|
"🎙 <i>Распознаю аудио и сверяю с документами дома № 37 в NotebookLM...</i>",
|
||||||
parse_mode='HTML'
|
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")
|
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)
|
||||||
|
|
||||||
# Передаем напрямую в мультимодальный Gemini
|
# Передаем в legal_engine (Gemini транскрибирует аудио в текст, затем NotebookLM ищет по базе дома)
|
||||||
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"❌ <b>Ошибка:</b> {result.get('text')}", parse_mode='HTML')
|
await message.answer(f"{result.get('text')}", parse_mode='HTML')
|
||||||
return
|
return
|
||||||
|
|
||||||
header = "⚖️ <b>Юридический ответ:</b>"
|
transcribed = result.get("transcribed_question")
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -99,8 +99,36 @@ 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 Юрист»."""
|
"""Прямой запрос к блокноту NotebookLM «ЛенКом37 Юрист» (RAG по документам дома № 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 = [
|
||||||
|
|
@ -112,7 +140,6 @@ 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()
|
||||||
|
|
@ -144,110 +171,100 @@ class AILegalEngine:
|
||||||
env=env
|
env=env
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=90.0)
|
# Увеличенный лимит ожидания (до 300 сек для глубокого RAG-анализа смет и решений)
|
||||||
|
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 {"success": False, "error": "Таймаут обращения к NotebookLM (более 90 сек)", "source": "notebooklm"}
|
return {
|
||||||
|
"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 = [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()
|
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[: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"}
|
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. Текстовые вопросы ВСЕХ пользователей идут строго в блокнот «Разбор ответов строго по ЖК РФ»!
|
# 1. Если передано аудиосообщение — транскрибируем его через Gemini Multimodal в текст
|
||||||
if not audio_path and prompt:
|
if audio_path:
|
||||||
nlm_res = await self.query_notebooklm(prompt)
|
log.info(f"Received audio voice message ({audio_path}), transcribing via Gemini STT...")
|
||||||
if nlm_res.get("success"):
|
transcribed_text = await self.transcribe_audio(audio_path)
|
||||||
latency = (datetime.now() - start_time).total_seconds()
|
if not transcribed_text:
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": False,
|
||||||
"text": nlm_res["text"],
|
"text": "❌ <b>Не удалось распознать голосовое сообщение.</b> Пожалуйста, напишите ваш вопрос текстом.",
|
||||||
"answer": nlm_res["text"],
|
|
||||||
"role": role,
|
"role": role,
|
||||||
"latency_sec": round(latency, 2),
|
"latency_sec": round((datetime.now() - start_time).total_seconds(), 2)
|
||||||
"model": "NotebookLM: Разбор ответов строго по ЖК РФ"
|
|
||||||
}
|
}
|
||||||
else:
|
log.info(f"Audio voice successfully transcribed: '{transcribed_text[:120]}...'")
|
||||||
log.warning(f"NotebookLM query failed: {nlm_res.get('error')}. Falling back to Gemini.")
|
prompt = transcribed_text
|
||||||
|
|
||||||
# 2. Резервный контур через Gemini API (для аудиосообщений либо если блокнот временно недоступен)
|
if not prompt or not prompt.strip():
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
system_instruction = (
|
# 2. Обращение СТРОГО к блокноту NotebookLM (RAG по сметам, договорам и протоколам ОСС)
|
||||||
"Ты — профессиональный юридический консультант по жилищному праву РФ (ЖК РФ, ГК РФ, защита прав потребителей в сфере ЖКХ).\n"
|
nlm_res = await self.query_notebooklm(prompt)
|
||||||
"ЖЕСТКИЕ ПРАВИЛА ОФОРМЛЕНИЯ:\n"
|
latency = round((datetime.now() - start_time).total_seconds(), 2)
|
||||||
"1. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО писать любые самопрезентации, самоназвания, титулы, приветствия или регалии (никаких 'Я — аналитик', 'Главный стратег', 'Юридический щит', 'Здравствуйте', 'Я помогу вам').\n"
|
|
||||||
"2. Ответ начинать СТРОГО сразу по существу вопроса.\n"
|
|
||||||
"3. Полный минимализм: только факты, правовые нормы, статьи законов и пошаговый алгоритм действий. Без рекламной шелухи и упоминания технологий/моделей.\n"
|
|
||||||
f"{self.get_role_instructions(role)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Каскад моделей при перегрузке или временной недоступности (503/429)
|
if nlm_res.get("success"):
|
||||||
models_to_try = [
|
return {
|
||||||
self.model_name,
|
"success": True,
|
||||||
"gemini-flash-latest",
|
"text": nlm_res["text"],
|
||||||
"gemini-3-flash-preview"
|
"answer": nlm_res["text"],
|
||||||
]
|
"role": role,
|
||||||
# Убираем дубликаты с сохранением порядка
|
"latency_sec": latency,
|
||||||
seen = set()
|
"model": "NotebookLM: ЛенКом37 Юрист (Документы дома № 37)",
|
||||||
models_cascade = [m for m in models_to_try if not (m in seen or seen.add(m))]
|
"transcribed_question": transcribed_text
|
||||||
|
}
|
||||||
last_err = None
|
else:
|
||||||
for model_to_use in models_cascade:
|
err_msg = nlm_res.get("error", "Неизвестная ошибка")
|
||||||
try:
|
log.error(f"NotebookLM legal query failed: {err_msg}")
|
||||||
reply_text = await self._query_rest_api(
|
# Никакого fallback на обычный Gemini! Выдаем честное уведомление об обновлении базы
|
||||||
prompt=prompt,
|
return {
|
||||||
system_instruction=system_instruction,
|
"success": False,
|
||||||
model=model_to_use,
|
"text": (
|
||||||
audio_path=audio_path
|
"❌ <b>База документов дома № 37 временно обновляет сессию.</b>\n\n"
|
||||||
)
|
"Юридический помощник не выдает выдуманных общих ответов нейросетей, "
|
||||||
|
"а обращается строго к протоколам ОСС, сметам и договорам нашего дома в NotebookLM. "
|
||||||
latency = (datetime.now() - start_time).total_seconds()
|
"Пожалуйста, повторите вопрос через минуту."
|
||||||
|
),
|
||||||
return {
|
"role": role,
|
||||||
"success": True,
|
"latency_sec": latency,
|
||||||
"text": reply_text.strip(),
|
"error_detail": err_msg
|
||||||
"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()
|
||||||
|
|
@ -255,7 +272,7 @@ legal_engine = AILegalEngine()
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
async def test():
|
async def test():
|
||||||
engine = AILegalEngine()
|
engine = AILegalEngine()
|
||||||
res = await engine.query("Какие сроки ответа прокуратуры по закону 59-ФЗ?", role="resident")
|
res = await engine.query("Сравни за что мы голосуем на общих собраниях - за обрезку или глубокую обрезку деревьев во дворе. А в отчетах - как списывают?")
|
||||||
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"))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue