domovoy_bot/handlers/legal_advisor.py

277 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
Legal Advisor Router (Юридический Помощник Домового 2.0).
Обрабатывает текстовые и голосовые юридические вопросы жильцов и совета дома.
Поддерживает ролевую модель:
- Жилец: простой бытовой язык + бланки заявлений.
- Юрист (has_ai_access=1): статьи ЖК РФ, судебная практика, досудебные претензии.
- Админ (Александр Тырин): стратегический анализ, сопоставление ответов чиновников.
Строгий шлюз Zero-Spam: ответы выдаются в личку либо направляются на одобрение админу.
"""
import os
import re
import html
import logging
from pathlib import Path
from aiogram import Router, F, Bot
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from sqlalchemy import select
from config import ADMIN_USER_ID
from database.db import AsyncSessionLocal
from database.models import User
from services.ai_legal_engine import legal_engine
log = logging.getLogger("LegalAdvisorHandler")
router = Router()
TEMP_VOICE_DIR = Path(__file__).resolve().parent.parent / "temp_voice"
TEMP_VOICE_DIR.mkdir(parents=True, exist_ok=True)
class LegalQuestionState(StatesGroup):
waiting_for_question = State()
def format_telegram_html(text: str) -> str:
"""Безопасное форматирование текста Markdown -> HTML для Telegram."""
clean = text.replace("<br>", "\n").replace("<br/>", "\n")
safe = html.escape(clean)
# **жирный** -> <b>жирный</b>
safe = re.sub(r'\*\*(.*?)\*\*', r'<b>\1</b>', safe, flags=re.DOTALL)
# *курсив* -> <i>курсив</i>
safe = re.sub(r'\*(.*?)\*', r'<i>\1</i>', safe, flags=re.DOTALL)
# [ссылка](url) -> <a href="url">ссылка</a>
safe = re.sub(r'\[(.*?)\]\((https?://.*?)\)', r'<a href="\2">\1</a>', safe, flags=re.DOTALL)
return safe
async def send_split_message(message: Message, header: str, body: str):
"""Отправляет длинный ответ аккуратными частями с защитой от разрыва тегов."""
# Разбиваем исходный текст по параграфам
paragraphs = body.split("\n\n")
chunks = []
current_chunk = []
current_len = 0
for p in paragraphs:
p_len = len(p) + 2
if current_len + p_len > 3200 and current_chunk:
chunks.append("\n\n".join(current_chunk))
current_chunk = [p]
current_len = p_len
else:
current_chunk.append(p)
current_len += p_len
if current_chunk:
chunks.append("\n\n".join(current_chunk))
if not chunks:
chunks = [body]
for idx, chunk in enumerate(chunks, 1):
if idx == 1:
part_header = f"{header}\n\n" if len(chunks) == 1 else f"{header} (Часть 1/{len(chunks)}):\n\n"
else:
part_header = f"<i>(Продолжение {idx}/{len(chunks)}):</i>\n\n"
part_text = f"{part_header}{format_telegram_html(chunk)}"
try:
await message.answer(part_text, parse_mode='HTML')
except Exception as e:
log.warning(f"HTML send failed ({e}), falling back to plain text")
plain_header = header if idx == 1 else f"(Продолжение {idx}/{len(chunks)}):"
await message.answer(f"{plain_header}\n\n{chunk}")
async def determine_user_role(user_id: int) -> str:
"""Определяет роль пользователя в системе."""
if user_id == ADMIN_USER_ID:
return "admin"
try:
async with AsyncSessionLocal() as session:
stmt = select(User).where(User.user_id == user_id)
user = (await session.execute(stmt)).scalar_one_or_none()
if user and (user.is_admin or user.has_ai_access):
return "lawyer"
except Exception as e:
log.warning(f"Failed to fetch user {user_id} role: {e}")
return "resident"
# === КОМАНДЫ АКТИВАЦИИ ===
@router.message(Command("jurist", "юрист", "вопрос", "help_law"))
@router.message(F.text.casefold().in_(["/юрист", "/jurist", "/вопрос", "юрист", "⚖️ юрист (жк рф)"]))
@router.message(F.text.casefold().startswith(("/юрист", "/jurist")))
async def cmd_legal_advisor(message: Message, state: FSMContext, bot: Bot):
role = await determine_user_role(message.from_user.id)
if role not in ("admin", "lawyer"):
await message.answer(
"🔒 <b>Модуль юридической аналитики находится в режиме калибровки Советом Дома.</b>\n\n"
"Доступ открывается индивидуально администратором (@ktybdsq).\n"
"Если вам требуется консультация по протечкам, тарифам или судебным спорам — напишите председателю Совета Дома.",
parse_mode='HTML'
)
return
# Проверяем, не задал ли пользователь вопрос прямо в команде: /юрист Какой срок ответа?
parts = (message.text or "").split(maxsplit=1)
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("⏳ <i>Анализирую нормативную базу дома...</i>", parse_mode='HTML')
result = await legal_engine.query(prompt=inline_question, role=role)
await status_msg.delete()
if result.get("success"):
header = f"⚖️ <b>Юридический ответ ({result.get('latency_sec')} сек):</b>"
await send_split_message(message, header, result["text"])
else:
await message.answer(f"❌ <b>Ошибка:</b> {result.get('text')}", parse_mode='HTML')
return
role_desc = {
"admin": "👑 <b>Режим Стратегического Аналитика (Администратор)</b>",
"lawyer": "⚖️ <b>Режим Судебного Юриста (Совет дома / Активист)</b>",
}.get(role, "🏡 <b>Юридический Помощник</b>")
text = (
f"{role_desc}\n\n"
"Задайте ваш вопрос по ЖКХ, протечкам, тарифам, собраниям или спорам с управляющей компанией.\n\n"
"🎙 <b>Вы можете написать текст или отправить голосовое сообщение!</b>\n"
"<i>Я проанализирую обращение по базе законов РФ и материалам дома.</i>"
)
await message.answer(text, parse_mode='HTML')
await state.set_state(LegalQuestionState.waiting_for_question)
@router.callback_query(F.data.in_(["admin_jurist", "jurist_ai", "main_jurist"]))
async def cb_legal_advisor(callback: CallbackQuery, state: FSMContext):
await callback.answer()
role = await determine_user_role(callback.from_user.id)
if role not in ("admin", "lawyer"):
await callback.message.answer(
"🔒 <b>Модуль юридической аналитики находится в режиме калибровки Советом Дома.</b>\n\n"
"Доступ открывается индивидуально администратором (@ktybdsq).\n"
"Если вам требуется консультация по протечкам, тарифам или судебным спорам — напишите председателю Совета Дома.",
parse_mode='HTML'
)
return
role_desc = {
"admin": "👑 <b>Режим Стратегического Аналитика (Администратор)</b>",
"lawyer": "⚖️ <b>Режим Судебного Юриста (Совет дома / Активист)</b>",
}.get(role, "🏡 <b>Юридический Помощник</b>")
text = (
f"{role_desc}\n\n"
"Задайте ваш вопрос по ЖКХ, протечкам, тарифам, собраниям или спорам с управляющей компанией.\n\n"
"🎙 <b>Вы можете написать текст или отправить голосовое сообщение!</b>\n"
"<i>Я проанализирую обращение по базе законов РФ и судебным материалам дома.</i>"
)
await callback.message.answer(text, parse_mode='HTML')
await state.set_state(LegalQuestionState.waiting_for_question)
# === ОБРАБОТКА ТЕКСТОВЫХ ВОПРОСОВ ===
@router.message(LegalQuestionState.waiting_for_question, F.text)
async def process_text_legal_question(message: Message, state: FSMContext, bot: Bot):
user_id = message.from_user.id
role = await determine_user_role(user_id)
if role not in ("admin", "lawyer"):
await message.answer(
"🔒 <b>Модуль юридической аналитики находится в режиме калибровки Советом Дома.</b>\n\n"
"Доступ открывается индивидуально администратором (@ktybdsq).",
parse_mode='HTML'
)
await state.clear()
return
question = message.text
status_msg = await message.answer(
"⏳ <i>Анализирую нормативную базу и материалы дома (1–3 сек)...</i>",
parse_mode='HTML'
)
result = await legal_engine.query(prompt=question, role=role)
await status_msg.delete()
if not result.get("success"):
await message.answer(f"❌ <b>Ошибка:</b> {result.get('text')}", parse_mode='HTML')
await state.clear()
return
# Защита от спама: если вопрос задан в групповом чате
if message.chat.type in ("group", "supergroup"):
# Ответ отправляется жильцу в ЛИЧКУ
try:
await bot.send_message(
chat_id=user_id,
text=f"⚖️ <b>Ответ на ваш вопрос из чата дома:</b>\n\n{format_telegram_html(result['text'])}",
parse_mode='HTML'
)
await message.reply(
"💬 <b>Ответ сформирован и отправлен вам в личные сообщения с ботом</b>, чтобы не перегружать общий чат дома.",
parse_mode='HTML'
)
except Exception:
await message.reply(
"💬 Пожалуйста, откройте личные сообщения с ботом (@lkmthreeseven_bot) и нажмите /start, чтобы получить ответ конфиденциально.",
parse_mode='HTML'
)
else:
# В личном диалоге отвечаем напрямую
header = f"⚖️ <b>Юридический ответ ({result.get('latency_sec')} сек):</b>"
await send_split_message(message, header, result["text"])
await state.clear()
# === ОБРАБОТКА ГОЛОСОВЫХ ВОПРОСОВ (НАТИВНОЕ МУЛЬТИМОДАЛЬНОЕ РАСПОЗНАВАНИЕ) ===
@router.message(LegalQuestionState.waiting_for_question, F.voice)
@router.message(F.voice & F.chat.type == "private")
async def process_voice_legal_question(message: Message, state: FSMContext, bot: Bot):
user_id = message.from_user.id
role = await determine_user_role(user_id)
if role not in ("admin", "lawyer"):
await message.answer(
"🔒 <b>Модуль юридической аналитики находится в режиме калибровки Советом Дома.</b>\n\n"
"Доступ открывается индивидуально администратором (@ktybdsq).",
parse_mode='HTML'
)
await state.clear()
return
status_msg = await message.answer(
"🎙 <i>Слушаю голосовое сообщение и сопоставляю с законами РФ...</i>",
parse_mode='HTML'
)
voice_file_path = None
try:
# Скачиваем голосовой файл
file_info = await bot.get_file(message.voice.file_id)
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
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"❌ <b>Ошибка распознавания:</b> {result.get('text')}", parse_mode='HTML')
return
header = f"🎙 <b>Голосовой разбор ({result.get('latency_sec')} сек):</b>"
await send_split_message(message, header, result["text"])
except Exception as e:
log.error(f"Voice processing error: {e}")
await status_msg.edit_text(f"❌ <b>Ошибка обработки голосового сообщения:</b> {e}")
finally:
# Всегда зачищаем временный аудиофайл
if voice_file_path and os.path.exists(voice_file_path):
try: os.unlink(voice_file_path)
except: pass
await state.clear()