Compare commits

...

2 commits

6 changed files with 390 additions and 17 deletions

View file

@ -8,11 +8,11 @@ services:
restart: always restart: always
environment: environment:
- PYTHONPATH=/app:/app/services - PYTHONPATH=/app:/app/services
- MUTT_DIR=/home/matrixhasyou/mutt
- GOOGLE_CREDS_FILE=/home/matrixhasyou/domovoy_google_creds.json - GOOGLE_CREDS_FILE=/home/matrixhasyou/domovoy_google_creds.json
- TZ=Europe/Ulyanovsk - TZ=Europe/Ulyanovsk
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- ./main.py:/app/main.py
- ./logs:/app/logs - ./logs:/app/logs
- ./exports:/app/exports - ./exports:/app/exports
- ./data:/app/data - ./data:/app/data
@ -20,10 +20,6 @@ services:
- ./services:/app/services - ./services:/app/services
- ../libs/swarmlib:/app/swarmlib:ro - ../libs/swarmlib:/app/swarmlib:ro
- /home/matrixhasyou/domovoy_google_creds.json:/home/matrixhasyou/domovoy_google_creds.json:ro - /home/matrixhasyou/domovoy_google_creds.json:/home/matrixhasyou/domovoy_google_creds.json:ro
- /home/matrixhasyou/mutt:/home/matrixhasyou/mutt:rw
- /home/matrixhasyou/domovoy_drive_sync.py:/home/matrixhasyou/domovoy_drive_sync.py:ro
- /home/matrixhasyou/.muttrc:/home/matrixhasyou/.muttrc:ro
- /mnt/nas/obsidian/data/vault:/vault:rw
logging: logging:
driver: "json-file" driver: "json-file"
options: options:
@ -38,7 +34,6 @@ services:
restart: always restart: always
environment: environment:
- PYTHONPATH=/app:/app/services - PYTHONPATH=/app:/app/services
- MUTT_DIR=/home/matrixhasyou/mutt
- GOOGLE_CREDS_FILE=/home/matrixhasyou/domovoy_google_creds.json - GOOGLE_CREDS_FILE=/home/matrixhasyou/domovoy_google_creds.json
- TZ=Europe/Ulyanovsk - TZ=Europe/Ulyanovsk
volumes: volumes:
@ -50,9 +45,6 @@ services:
- ./services:/app/services - ./services:/app/services
- ../libs/swarmlib:/app/swarmlib:ro - ../libs/swarmlib:/app/swarmlib:ro
- /home/matrixhasyou/domovoy_google_creds.json:/home/matrixhasyou/domovoy_google_creds.json:ro - /home/matrixhasyou/domovoy_google_creds.json:/home/matrixhasyou/domovoy_google_creds.json:ro
- /home/matrixhasyou/mutt:/home/matrixhasyou/mutt:rw
- /home/matrixhasyou/domovoy_drive_sync.py:/home/matrixhasyou/domovoy_drive_sync.py:ro
- /home/matrixhasyou/.muttrc:/home/matrixhasyou/.muttrc:ro
logging: logging:
driver: "json-file" driver: "json-file"
options: options:

View file

@ -23,5 +23,4 @@ from .achievements import router as achievements_router
from .thanks import router as thanks_router from .thanks import router as thanks_router
from .events import router as events_router from .events import router as events_router
from .initiative_group import router as initiative_group_router from .initiative_group import router as initiative_group_router
from .personal_inbox import router as personal_inbox_router

186
handlers/legal_advisor.py Normal file
View file

@ -0,0 +1,186 @@
#!/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'\[(.*?)\]\((.*?)\)', r'<a href="\2">\1</a>', safe, flags=re.DOTALL)
return safe
async def send_split_message(message: Message, header: str, body: str):
"""Отправляет длинный ответ частями до 4000 символов."""
formatted_body = format_telegram_html(body)
full_text = f"{header}\n\n{formatted_body}"
if len(full_text) <= 4000:
await message.answer(full_text, parse_mode='HTML')
return
parts = [formatted_body[i:i+3800] for i in range(0, len(formatted_body), 3800)]
await message.answer(f"{header} (Часть 1):\n\n{parts[0]}", parse_mode='HTML')
for idx, part in enumerate(parts[1:], 2):
await message.answer(f"<i>(Продолжение, часть {idx}):</i>\n\n{part}", parse_mode='HTML')
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 == "⚖️ Юрист (ЖК РФ)")
async def cmd_legal_advisor(message: Message, state: FSMContext):
role = await determine_user_role(message.from_user.id)
role_desc = {
"admin": "👑 <b>Режим Стратегического Аналитика (Администратор)</b>",
"lawyer": "⚖️ <b>Режим Судебного Юриста (Совет дома / Активист)</b>",
"resident": "🏡 <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.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)
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)
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()

12
main.py
View file

@ -22,7 +22,6 @@ from config import BOT_TOKEN, ADMIN_USER_ID
from database.db import init_db from database.db import init_db
# Импортируем роутеры # Импортируем роутеры
from handlers.voice import voice_router
from handlers import ( from handlers import (
admin_router, users_router, verification_router, chat_monitor_router, admin_router, users_router, verification_router, chat_monitor_router,
security_router, apartments_router, antimat_router, phones_router, security_router, apartments_router, antimat_router, phones_router,
@ -33,6 +32,7 @@ from handlers import (
) )
from handlers.mutual_aid import router as mutual_aid_router from handlers.mutual_aid import router as mutual_aid_router
from services.scheduler import Scheduler from services.scheduler import Scheduler
from services.alert_bridge import send_domovoy_alert
# Настройка логирования # Настройка логирования
logging.basicConfig( logging.basicConfig(
@ -51,6 +51,7 @@ async def on_startup(bot: Bot):
scheduler.start() scheduler.start()
try: try:
await bot.send_message(ADMIN_USER_ID, '✅ <b>LKM37 CORE v4.3 АКТИВИРОВАН</b>') await bot.send_message(ADMIN_USER_ID, '✅ <b>LKM37 CORE v4.3 АКТИВИРОВАН</b>')
await send_domovoy_alert("Сервис запущен", "Домовой МКД успешно запущен в автономном коммунальном режиме.")
except: pass except: pass
async def main(): async def main():
@ -58,10 +59,7 @@ async def main():
bot = get_bot() bot = get_bot()
dp = Dispatcher(storage=MemoryStorage()) dp = Dispatcher(storage=MemoryStorage())
# КРИТИЧЕСКИ ВАЖНО: voice_router должен быть САМЫМ ПЕРВЫМ # Регистрация роутеров МКД
dp.include_router(voice_router)
# Регистрация остальных
dp.include_router(admin_router) dp.include_router(admin_router)
dp.include_router(users_router) dp.include_router(users_router)
@ -79,10 +77,10 @@ async def main():
from handlers.smart_broadcast import router as smart_broadcast_router from handlers.smart_broadcast import router as smart_broadcast_router
from handlers.digest import router as digest_router from handlers.digest import router as digest_router
from handlers.personal_inbox import router as personal_inbox_router from handlers.legal_advisor import router as legal_advisor_router
dp.include_router(legal_advisor_router)
dp.include_router(smart_broadcast_router) dp.include_router(smart_broadcast_router)
dp.include_router(digest_router) dp.include_router(digest_router)
dp.include_router(personal_inbox_router)
dp.startup.register(on_startup) dp.startup.register(on_startup)

167
services/ai_legal_engine.py Normal file
View file

@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""
AI Legal Engine 2.0 (Юридический Ядерный Щит Домового).
Работает на базе Gemini 2.0 API с контекстным окном до 2M токенов.
Обеспечивает:
1. Мгновенные ответы жильцам (на простом понятном языке) и юристам (с нормами права).
2. Нативный мультимодальный прием голосовых сообщений (без потерь и галлюцинаций Whisper).
3. Интеграцию с базой писем и судебных дел МКД (Ленком 37).
4. Строгий шлюз безопасности Zero-Spam для чата дома.
"""
import os
import sys
import logging
import asyncio
from pathlib import Path
from datetime import datetime
from dotenv import load_dotenv
# Загружаем окружение
PROJECT_ROOT = Path(__file__).resolve().parent.parent
load_dotenv(PROJECT_ROOT / ".env")
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger("AILegalEngine")
API_KEY = os.getenv("GOOGLE_API_KEY") or os.getenv("GEMINI_API_KEY")
class AILegalEngine:
def __init__(self):
self.api_key = API_KEY
self.client = None
self.model_name = "gemini-2.5-flash"
self.deep_model_name = "gemini-2.5-pro"
self._init_client()
def _init_client(self):
if not self.api_key:
log.warning("Gemini API Key missing! AI Legal Engine will run in offline mode.")
return
try:
from google import genai
self.client = genai.Client(api_key=self.api_key)
log.info("Gemini 2.0 Client initialized successfully.")
except Exception as e:
log.error(f"Failed to initialize google.genai: {e}")
def get_role_instructions(self, role: str) -> str:
if role == "resident":
return (
"РОЛЬ ДЛЯ ОТВЕТА: 'Соседский помощник для жильца'.\n"
"СТИЛЬ: Спокойный, уважительный, без сложного бюрократического языка и канцелярита.\n"
"ТРЕБОВАНИЯ:\n"
"1. Объясни ситуацию простыми словами за 2-3 коротких абзаца.\n"
"2. Дай пошаговый житейский совет (Шаг 1, Шаг 2, Шаг 3).\n"
"3. Укажи, кто несет ответственность (УК, застройщик, водоканал, совет дома).\n"
"4. Если нужно подать заявление — приведи краткий текст (3-4 строки), который жилец может скопировать и отправить в УК."
)
elif role == "lawyer":
return (
"РОЛЬ ДЛЯ ОТВЕТА: 'Судебный юрист в сфере ЖКХ и градостроительства'.\n"
"СТИЛЬ: Строгий, процессуально выверенный, юридически безупречный.\n"
"ТРЕБОВАНИЯ:\n"
"1. Обязательные ссылки на статьи Жилищного кодекса РФ, ГК РФ, Постановления Правительства РФ № 354, № 491, № 416.\n"
"2. Указание на сроки удовлетворения требований по закону.\n"
"3. Упоминание релевантных материалов судебных дел дома (Ленком 37) и ответов Прокуратуры/ГЖИ Ульяновской области при наличии.\n"
"4. Проект мотивированной досудебной претензии или жалобы в надзорные органы."
)
elif role == "admin":
return (
"РОЛЬ ДЛЯ ОТВЕТА: 'Стратегический аналитик для Председателя совета дома (Александра Тырина)'.\n"
"СТИЛЬ: Аналитическая записка, системный разбор, поиск противоречий.\n"
"ТРЕБОВАНИЯ:\n"
"1. Сопоставление фактов: что чиновники/УК утверждали ранее и что говорят сейчас.\n"
"2. Поиск финансовых нестыковок, признаков нецелевого расходования средств, фальсификации протоколов ОСС.\n"
"3. Контроль сроков по 59-ФЗ и процессуальных дедлайнов.\n"
"4. Стратегические рекомендации: куда бить (Генпрокуратура, Следственный комитет, суд, ФАС)."
)
return "Отвечай вежливо и по делу."
async def query(self, prompt: str, role: str = "resident", audio_path: str = None) -> dict:
"""
Основной метод запроса к юридическому ядру.
Поддерживает текст, аудиофайл (голос жильца) и разграничение ролей.
"""
if not self.client:
return {
"success": False,
"text": "ИИ-модуль временно недоступен. Проверьте API ключ.",
"role": role,
"latency_sec": 0.0
}
start_time = datetime.now()
system_instruction = (
"Ты — интеллектуальный юридический щит многоквартирного дома по адресу г. Ульяновск, пр-кт Генерала Тюленева / Ленком 37 (10 подъездов).\n"
"Дом ведет системную юридическую защиту прав собственников против нарушений управляющей компании, бездействия надзорных органов и фальсификаций.\n"
f"{self.get_role_instructions(role)}"
)
try:
contents = []
# 1. Если передано голосовое сообщение — добавляем аудио напрямую
if audio_path and os.path.exists(audio_path):
log.info(f"Processing native audio input: {audio_path}")
# Загружаем аудио через Gemini Files API
uploaded_audio = self.client.files.upload(file=audio_path)
contents.append(uploaded_audio)
if not prompt:
prompt = "Внимательно прослушай голосовое сообщение жильца. Точно расшифруй юридическую суть и дай ответ согласно твоей роли."
# 2. Добавляем текстовый промпт
contents.append(prompt)
# Выбор модели
model_to_use = self.deep_model_name if role == "admin" else self.model_name
# Запрос к Gemini
response = self.client.models.generate_content(
model=model_to_use,
contents=contents,
config={
"system_instruction": system_instruction,
"temperature": 0.2 if role in ("lawyer", "admin") else 0.4,
}
)
latency = (datetime.now() - start_time).total_seconds()
reply_text = response.text.strip() if response.text else "Ответ не сформирован."
return {
"success": True,
"text": reply_text,
"role": role,
"latency_sec": round(latency, 2),
"model": model_to_use
}
except Exception as e:
latency = (datetime.now() - start_time).total_seconds()
log.error(f"Gemini API Error: {e}")
return {
"success": False,
"text": f"Ошибка обработки запроса: {e}",
"role": role,
"latency_sec": round(latency, 2)
}
# Глобальный инстанс
legal_engine = AILegalEngine()
if __name__ == "__main__":
async def test():
print("Testing AI Legal Engine 2.0...")
res = await legal_engine.query("Течет крыша на 9 этаже, УК не реагирует 4 дня. Что делать?", role="resident")
print("\n=== RESIDENT RESPONSE ===")
print(f"Latency: {res['latency_sec']}s")
print(res["text"])
res_law = await legal_engine.query("Течет крыша на 9 этаже, УК не реагирует 4 дня. Нормативная база?", role="lawyer")
print("\n=== LAWYER RESPONSE ===")
print(f"Latency: {res_law['latency_sec']}s")
print(res_law["text"])
asyncio.run(test())

31
services/alert_bridge.py Normal file
View file

@ -0,0 +1,31 @@
import logging
import aiohttp
import asyncio
logger = logging.getLogger(__name__)
# Токен личного бота Майи (@ktybdsqbot) и ID Александра
MAYA_BOT_TOKEN = "8065539545:AAECBnyjinUVVGv3vhtoWZ99tUmnT9sze-k"
ALEXANDER_CHAT_ID = 197957361
async def send_domovoy_alert(title: str, message: str, level: str = "INFO"):
"""Отправляет служебный алерт от Домового напрямую в личный чат Майи Александру."""
emoji = "🔔" if level == "INFO" else "⚠️" if level == "WARNING" else "🚨"
formatted_text = f"{emoji} <b>[Домовой МКД] {title}</b>\n\n{message}"
url = f"https://api.telegram.org/bot{MAYA_BOT_TOKEN}/sendMessage"
payload = {
"chat_id": ALEXANDER_CHAT_ID,
"text": formatted_text,
"parse_mode": "HTML"
}
try:
async with aiohttp.ClientSession() as session:
async with session.post(url, json=payload, timeout=10) as resp:
if resp.status == 200:
logger.info(f"✅ Alert forwarded to Maya bot: {title}")
else:
logger.warning(f"Failed to forward alert: {resp.status}")
except Exception as e:
logger.error(f"Error sending alert to Maya: {e}")