feat(ui): Lissyara UNIX-Minimalism redesign and NotebookLM podcast integration

This commit is contained in:
Admin 2026-10-13 09:20:35 +04:00
parent 6a4610748e
commit c49003bf39
8 changed files with 2975 additions and 257 deletions

View file

@ -12,6 +12,7 @@ services:
- TZ=Europe/Ulyanovsk
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./.env:/app/.env:ro
- ./main.py:/app/main.py
- ./logs:/app/logs
- ./exports:/app/exports
@ -35,14 +36,19 @@ services:
environment:
- PYTHONPATH=/app:/app/services
- GOOGLE_CREDS_FILE=/home/matrixhasyou/domovoy_google_creds.json
- GOOGLE_API_KEY=AIzaSyAJMfQ_mRZ1picRXVFNaeFJ7fpAkZxUKyk
- TZ=Europe/Ulyanovsk
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./.env:/app/.env:ro
- ./web:/app/web
- ./podcasts:/app/podcasts
- ./logs:/app/logs
- ./exports:/app/exports
- ./data:/app/data
- ./handlers:/app/handlers
- ./services:/app/services
- /home/matrixhasyou/mutt:/home/matrixhasyou/mutt:ro
- ../libs/swarmlib:/app/swarmlib:ro
- /home/matrixhasyou/domovoy_google_creds.json:/home/matrixhasyou/domovoy_google_creds.json:ro
logging:

View file

@ -1,40 +1,32 @@
#!/usr/bin/env python3
"""
AI Legal Engine 2.0 (Юридический Ядерный Щит Домового).
Работает на базе Gemini 2.0 API с контекстным окном до 2M токенов.
Обеспечивает:
1. Мгновенные ответы жильцам (на простом понятном языке) и юристам (с нормами права).
2. Нативный мультимодальный прием голосовых сообщений (без потерь и галлюцинаций Whisper).
3. Интеграцию с базой писем и судебных дел МКД (Ленком 37).
4. Строгий шлюз безопасности Zero-Spam для чата дома.
Интеллектуальное юридическое ядро МКД «Домовой 2.0» (AI Legal Engine)
Работает на базе Gemini 2.5 Flash / Pro:
- Нативный слух (Gemini Multimodal Audio) без Whisper
- Прямой асинхронный REST API (высокая отказоустойчивость без конфликтов pydantic)
- Разграничение ролей: resident (соседский помощник), lawyer (претензионно-судебный), admin (глубокий аудит)
"""
import os
import sys
import logging
import asyncio
from pathlib import Path
import aiohttp
import base64
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")
from pathlib import Path
log = logging.getLogger("ai_legal_engine")
class AILegalEngine:
def __init__(self):
self.api_key = API_KEY
raw_key = os.getenv("GOOGLE_API_KEY", "AIzaSyAJMfQ_mRZ1picRXVFNaeFJ7fpAkZxUKyk")
self.api_key = raw_key.strip("\"'") if raw_key else ""
self.client = None
self.model_name = "gemini-2.5-flash"
self.deep_model_name = "gemini-2.5-pro"
self._init_client()
self._init_sdk_client()
def _init_client(self):
def _init_sdk_client(self):
if not self.api_key:
log.warning("Gemini API Key missing! AI Legal Engine will run in offline mode.")
return
@ -42,9 +34,9 @@ class AILegalEngine:
try:
from google import genai
self.client = genai.Client(api_key=self.api_key)
log.info("Gemini 2.0 Client initialized successfully.")
log.info("Gemini SDK Client initialized.")
except Exception as e:
log.error(f"Failed to initialize google.genai: {e}")
log.info(f"Using high-performance direct REST engine (SDK not active: {e})")
def get_role_instructions(self, role: str) -> str:
if role == "resident":
@ -59,32 +51,69 @@ class AILegalEngine:
)
elif role == "lawyer":
return (
"РОЛЬ ДЛЯ ОТВЕТА: 'Судебный юрист в сфере ЖКХ и градостроительства'.\n"
"СТИЛЬ: Строгий, процессуально выверенный, юридически безупречный.\n"
"РОЛЬ ДЛЯ ОТВЕТА: 'Профессиональный юрист по жилищному праву и защите прав потребителей'.\n"
"СТИЛЬ: Строгий, юридически выверенный, аргументированный.\n"
"ТРЕБОВАНИЯ:\n"
"1. Обязательные ссылки на статьи Жилищного кодекса РФ, ГК РФ, Постановления Правительства РФ № 354, № 491, № 416.\n"
"2. Указание на сроки удовлетворения требований по закону.\n"
"3. Упоминание релевантных материалов судебных дел дома (Ленком 37) и ответов Прокуратуры/ГЖИ Ульяновской области при наличии.\n"
"4. Проект мотивированной досудебной претензии или жалобы в надзорные органы."
"1. Четкая правовая квалификация действий/бездействия лиц.\n"
"2. Прямые ссылки на статьи законов (ЖК РФ, ГК РФ, 59-ФЗ, ПП РФ № 354, ПП РФ № 491, Госстрой № 170, СанПиН 2.1.3684-21).\n"
"3. Оценка перспектив в суде или надзорных органах (ГЖИ, Прокуратура, Роспотребнадзор).\n"
"4. Готовый проект претензии / жалобы с правовой мотивировкой."
)
elif role == "admin":
return (
"РОЛЬ ДЛЯ ОТВЕТА: 'Стратегический аналитик для Председателя совета дома (Александра Тырина)'.\n"
"СТИЛЬ: Аналитическая записка, системный разбор, поиск противоречий.\n"
"РОЛЬ ДЛЯ ОТВЕТА: 'Главный аналитик Совета Дома и стратег судебных процессов'.\n"
"СТИЛЬ: Стратегический, системный, аналитический.\n"
"ТРЕБОВАНИЯ:\n"
"1. Сопоставление фактов: что чиновники/УК утверждали ранее и что говорят сейчас.\n"
"2. Поиск финансовых нестыковок, признаков нецелевого расходования средств, фальсификации протоколов ОСС.\n"
"3. Контроль сроков по 59-ФЗ и процессуальных дедлайнов.\n"
"4. Стратегические рекомендации: куда бить (Генпрокуратура, Следственный комитет, суд, ФАС)."
"1. Полный расклад с точки зрения системной выгоды для всего дома.\n"
"2. Выявление уловок УК, скрытых манипуляций со сметами, протоколами ОСС или объемами коммунальных ресурсов.\n"
"3. Сравнение с прецедентами и судебной практикой Ульяновского областного суда и Верховного Суда РФ.\n"
"4. Стратегия принуждения госорганов к исполнению служебных обязанностей по 59-ФЗ и Приказу ГП № 45."
)
return "Отвечай вежливо и по делу."
async def _query_rest_api(self, prompt: str, system_instruction: str, model: str, audio_path: str = None) -> str:
"""Прямой асинхронный REST запрос к Google Gemini API."""
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={self.api_key}"
parts = []
# Аудио вложение
if audio_path and os.path.exists(audio_path):
with open(audio_path, "rb") as af:
audio_b64 = base64.b64encode(af.read()).decode("utf-8")
parts.append({
"inlineData": {
"mimeType": "audio/ogg",
"data": audio_b64
}
})
if not prompt:
prompt = "Внимательно прослушай аудиосообщение жильца дома. Точно выдели юридическую суть и дай обоснованный ответ."
parts.append({"text": prompt})
payload = {
"system_instruction": {
"parts": [{"text": system_instruction}]
},
"contents": [
{"parts": parts}
],
"generationConfig": {
"temperature": 0.2 if model == self.deep_model_name else 0.4
}
}
async with aiohttp.ClientSession() as session:
async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=45)) as resp:
if resp.status != 200:
err_text = await resp.text()
raise RuntimeError(f"Gemini API HTTP {resp.status}: {err_text[:300]}")
data = await resp.json()
return data["candidates"][0]["content"]["parts"][0]["text"]
async def query(self, prompt: str, role: str = "resident", audio_path: str = None) -> dict:
"""
Основной метод запроса к юридическому ядру.
Поддерживает текст, аудиофайл (голос жильца) и разграничение ролей.
"""
if not self.client:
"""Основной метод запроса к юридическому ядру."""
if not self.api_key:
return {
"success": False,
"text": "ИИ-модуль временно недоступен. Проверьте API ключ.",
@ -99,40 +128,22 @@ class AILegalEngine:
f"{self.get_role_instructions(role)}"
)
model_to_use = self.deep_model_name if role in ("lawyer", "admin") else self.model_name
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(
# Используем прямой REST (наивысшая надежность без конфликтов библиотек)
reply_text = await self._query_rest_api(
prompt=prompt,
system_instruction=system_instruction,
model=model_to_use,
contents=contents,
config={
"system_instruction": system_instruction,
"temperature": 0.2 if role in ("lawyer", "admin") else 0.4,
}
audio_path=audio_path
)
latency = (datetime.now() - start_time).total_seconds()
reply_text = response.text.strip() if response.text else "Ответ не сформирован."
return {
"success": True,
"text": reply_text,
"text": reply_text.strip(),
"answer": reply_text.strip(),
"role": role,
"latency_sec": round(latency, 2),
"model": model_to_use
@ -140,28 +151,24 @@ class AILegalEngine:
except Exception as e:
latency = (datetime.now() - start_time).total_seconds()
log.error(f"Gemini API Error: {e}")
log.error(f"Gemini Error: {e}")
return {
"success": False,
"text": f"Ошибка обработки запроса: {e}",
"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"])
engine = AILegalEngine()
res = await engine.query("Какие сроки ответа прокуратуры по закону 59-ФЗ?", role="resident")
print("Success:", res["success"])
print("Latency:", res["latency_sec"])
print("Model:", res.get("model"))
print("Text snippet:\n", res["text"][:300])
asyncio.run(test())

View file

@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""
Сервис интеграции с Google NotebookLM и генерации аудио-подкастов (Audio Overviews)
для проекта «Домовой 2.0: Юридический Ядерный Щит МКД».
Возможности:
1. Автоматическая синхронизация официальных источников (полные законы РФ + тома почты с ЭЦП).
2. Вызов генерации двухголосых аудио-обзоров NotebookLM (Audio Overview).
3. Скачивание аудио-подкастов (.m4a/.mp3) в локальное хранилище.
4. Fallback: Генерация детального сценария аналитического подкаста через Gemini 2.5 Pro.
"""
import os
import sys
import json
import logging
import subprocess
from pathlib import Path
from typing import Dict, Any, Optional, List
logger = logging.getLogger("notebooklm_podcast")
logger.setLevel(logging.INFO)
NOTEBOOKLM_BIN = "/home/matrixhasyou/swarm-services/domovoy-bot/venv-ai/bin/notebooklm"
PODCASTS_DIR = Path("/home/matrixhasyou/swarm-services/domovoy-bot/podcasts")
LEGAL_SOURCES_DIR = Path("/home/matrixhasyou/mutt/legal_sources")
MEGA_VOLUMES_DIR = Path("/home/matrixhasyou/mutt/mega_volumes")
DEFAULT_NOTEBOOK_NAME = "МКД Ливанова 37 (Суды, Прокуратура, ЖКХ)"
class NotebookLMPodcastGenerator:
def __init__(self, notebook_name: str = DEFAULT_NOTEBOOK_NAME):
self.notebook_name = notebook_name
self.cli_bin = NOTEBOOKLM_BIN
PODCASTS_DIR.mkdir(parents=True, exist_ok=True)
def is_authenticated(self) -> bool:
"""Проверяет, авторизован ли CLI NotebookLM."""
storage_file = Path(os.path.expanduser("~/.notebooklm/profiles/default/storage_state.json"))
if not storage_file.exists() and not os.getenv("NOTEBOOKLM_AUTH_JSON"):
return False
try:
res = subprocess.run([self.cli_bin, "list"], capture_output=True, text=True, timeout=10)
return res.returncode == 0 and "Not logged in" not in res.stderr and "Not logged in" not in res.stdout
except Exception:
return False
def list_notebooks(self) -> List[Dict[str, str]]:
"""Возвращает список блокнотов из NotebookLM."""
if not self.is_authenticated():
return []
try:
res = subprocess.run([self.cli_bin, "list", "--json"], capture_output=True, text=True, timeout=15)
if res.returncode == 0:
data = json.loads(res.stdout)
return data if isinstance(data, list) else []
except Exception as e:
logger.error(f"Ошибка получения списка блокнотов: {e}")
return []
def get_or_create_notebook(self) -> Optional[str]:
"""Находит существующий блокнот дома или создает новый."""
notebooks = self.list_notebooks()
for nb in notebooks:
if self.notebook_name.lower() in nb.get("title", "").lower():
return nb.get("id")
# Создаем новый
try:
res = subprocess.run([self.cli_bin, "create", self.notebook_name], capture_output=True, text=True, timeout=20)
if res.returncode == 0:
# Находим созданный
for nb in self.list_notebooks():
if self.notebook_name.lower() in nb.get("title", "").lower():
return nb.get("id")
except Exception as e:
logger.error(f"Ошибка создания блокнота: {e}")
return None
def upload_source_file(self, file_path: str, notebook_id: Optional[str] = None) -> bool:
"""Загружает PDF-файл (закон или том почты) в NotebookLM."""
if not os.path.exists(file_path):
logger.error(f"Файл не найден: {file_path}")
return False
args = [self.cli_bin, "source", "add", file_path]
if notebook_id:
args.extend(["--notebook", notebook_id])
try:
res = subprocess.run(args, capture_output=True, text=True, timeout=180)
return res.returncode == 0
except Exception as e:
logger.error(f"Ошибка загрузки источника {file_path}: {e}")
return False
def sync_all_sources(self) -> Dict[str, Any]:
"""Синхронизирует все полные законы и тома мега-PDF с NotebookLM."""
if not self.is_authenticated():
return {
"success": False,
"error": "NotebookLM CLI не авторизован. Требуется импорт профиля в ~/.notebooklm/profiles/default/"
}
nb_id = self.get_or_create_notebook()
if not nb_id:
return {"success": False, "error": "Не удалось создать или получить блокнот дома"}
uploaded = []
# 1. Законы РФ
if LEGAL_SOURCES_DIR.exists():
for pdf in sorted(LEGAL_SOURCES_DIR.glob("*Полный*.pdf")):
if self.upload_source_file(str(pdf), nb_id):
uploaded.append(pdf.name)
# 2. Мега-тома писем
if MEGA_VOLUMES_DIR.exists():
for pdf in sorted(MEGA_VOLUMES_DIR.glob("*.pdf")):
if self.upload_source_file(str(pdf), nb_id):
uploaded.append(pdf.name)
return {
"success": True,
"notebook_id": nb_id,
"uploaded_sources": uploaded
}
def generate_audio_overview(self, notebook_id: Optional[str] = None) -> Dict[str, Any]:
"""Запускает генерацию двухголосного подкаста в NotebookLM."""
if not self.is_authenticated():
return {
"success": False,
"error": "NotebookLM не авторизован. Доступен fallback-режим генерации через Gemini 2.5."
}
args = [self.cli_bin, "generate", "audio"]
if notebook_id:
args.extend(["--notebook", notebook_id])
try:
res = subprocess.run(args, capture_output=True, text=True, timeout=300)
if res.returncode == 0:
return {"success": True, "output": res.stdout.strip()}
return {"success": False, "error": res.stderr.strip() or res.stdout.strip()}
except Exception as e:
return {"success": False, "error": str(e)}
def download_latest_audio(self, output_filename: str = "latest_podcast.m4a") -> Optional[str]:
"""Скачивает готовый сгенерированный подкаст."""
dest = PODCASTS_DIR / output_filename
args = [self.cli_bin, "download", "audio", "--output", str(dest)]
try:
res = subprocess.run(args, capture_output=True, text=True, timeout=120)
if res.returncode == 0 and dest.exists():
return str(dest)
except Exception as e:
logger.error(f"Ошибка скачивания аудио: {e}")
return None
def generate_fallback_dialogue_script(self, context_summary: str) -> Dict[str, Any]:
"""
Fallback генератор: при отсутствии сессии NotebookLM формирует
полноценный драматический сценарий двухголосного подкаста через Gemini 2.5 Pro.
"""
from google import genai
from google.genai import types
api_key = os.getenv("GOOGLE_API_KEY", "AIzaSyAJMfQ_mRZ1picRXVFNaeFJ7fpAkZxUKyk")
client = genai.Client(api_key=api_key)
prompt = f"""Ты — ведущий сценарист и главный аналитик юридического аудио-подкаста дома «Ливанова 37: Голос Закона».
Твоя задача — составить живой, увлекательный, юридически безупречный сценарий подкаста на 7-10 минут (в формате диалога двух экспертов):
Ведущие:
1. Александр (Ведущий / Председатель Совета Дома) — задает острые вопросы жителей, приводит факты из писем госорганов, вскрывает уловки УК и прокуратуры.
2. Майя (Юрист-аналитик по жилищному праву) — профессионально, с юмором и железобетонными ссылками на законы (ЖК РФ, 59-ФЗ, ПП 354, Госстрой 170) объясняет, в чем неправы чиновники и как жителям защитить свои права.
Контекст переписки и правовой базы дома:
{context_summary}
Формат ответа:
Заголовок подкаста.
Текст реплик с пометками [Александр] и [Майя].
В конце — четкие выводы и 3 практических шага для жителей дома.
"""
response = client.models.generate_content(
model="gemini-2.5-pro",
contents=prompt,
config=types.GenerateContentConfig(
temperature=0.4
)
)
return {
"success": True,
"script": response.text,
"engine": "gemini-2.5-pro-fallback"
}
if __name__ == "__main__":
generator = NotebookLMPodcastGenerator()
print("NotebookLM Auth:", generator.is_authenticated())
if not generator.is_authenticated():
print("Running fallback test...")
res = generator.generate_fallback_dialogue_script(
"Судебные тяжбы с УК по перерасчету отопления, игнорирование прокуратурой Заволжского района сроков 59-ФЗ."
)
print("Generated script snippet:\n", res["script"][:400])

View file

@ -910,6 +910,131 @@ async def api_sudrf_status(username: str = Depends(get_current_admin)):
except Exception as e:
return JSONResponse(status_code=500, content={"message": str(e)})
@app.get("/legal", response_class=HTMLResponse)
async def legal_page(request: Request, username: str = Depends(get_current_admin)):
import fitz
legal_dir = Path("/home/matrixhasyou/mutt/legal_sources")
volumes_dir = Path("/home/matrixhasyou/mutt/mega_volumes")
podcasts_dir = Path("/home/matrixhasyou/swarm-services/domovoy-bot/podcasts")
laws = []
if legal_dir.exists():
for f in sorted(legal_dir.glob("*.pdf")):
if f.is_file() and not f.name.startswith("."):
size_mb = f.stat().st_size / (1024 * 1024)
size_formatted = f"{size_mb:.2f} МБ" if size_mb >= 1 else f"{f.stat().st_size / 1024:.1f} КБ"
try:
doc = fitz.open(f)
pages = len(doc)
except Exception:
pages = 0
title = f.stem.replace("_", " ").replace("Полный", "").strip()
laws.append({
"filename": f.name,
"title": title,
"pages": pages,
"size_formatted": size_formatted
})
mega_volumes = []
if volumes_dir.exists():
for f in sorted(volumes_dir.glob("*.pdf")):
if f.is_file() and not f.name.startswith("."):
size_mb = f.stat().st_size / (1024 * 1024)
mega_volumes.append({
"name": f.name,
"size_formatted": f"{size_mb:.1f} МБ"
})
latest_podcast = None
if podcasts_dir.exists():
podcast_files = sorted(podcasts_dir.glob("*.*"), key=lambda x: x.stat().st_mtime, reverse=True)
if podcast_files:
p = podcast_files[0]
latest_podcast = {
"name": p.name,
"size": f"{p.stat().st_size / (1024 * 1024):.1f} МБ",
"date": datetime.fromtimestamp(p.stat().st_mtime).strftime("%d.%m.%Y %H:%M")
}
return templates.TemplateResponse(
request=request,
name="legal.html",
context={
"username": username,
"laws": laws,
"mega_volumes": mega_volumes,
"latest_podcast": latest_podcast
}
)
@app.get("/api/legal/download/{filename}")
async def api_download_law(filename: str, username: str = Depends(get_current_admin)):
fpath = Path("/home/matrixhasyou/mutt/legal_sources") / filename
if not fpath.exists() or not fpath.is_file():
raise HTTPException(status_code=404, detail="Файл закона не найден")
return FileResponse(fpath, filename=filename, media_type="application/pdf")
@app.get("/api/legal/download_volume/{filename}")
async def api_download_volume(filename: str, username: str = Depends(get_current_admin)):
fpath = Path("/home/matrixhasyou/mutt/mega_volumes") / filename
if not fpath.exists() or not fpath.is_file():
raise HTTPException(status_code=404, detail="Файл тома переписки не найден")
return FileResponse(fpath, filename=filename, media_type="application/pdf")
@app.get("/api/legal/podcasts/{filename}")
async def api_download_podcast(filename: str, username: str = Depends(get_current_admin)):
fpath = Path("/home/matrixhasyou/swarm-services/domovoy-bot/podcasts") / filename
if not fpath.exists() or not fpath.is_file():
raise HTTPException(status_code=404, detail="Подкаст не найден")
return FileResponse(fpath, filename=filename)
@app.post("/api/legal/ask_ai")
async def api_legal_ask_ai(request: Request, username: str = Depends(get_current_admin)):
try:
body = await request.json()
query = body.get("query", "").strip()
deep = body.get("deep", False)
if not query:
return JSONResponse(content={"success": False, "error": "Текст вопроса пуст"})
from services.ai_legal_engine import AILegalEngine
engine = AILegalEngine()
role = "lawyer" if deep else "resident"
res = await engine.query(prompt=query, role=role)
return JSONResponse(content=res)
except Exception as e:
return JSONResponse(content={"success": False, "error": str(e)})
@app.post("/api/legal/notebooklm_sync")
async def api_legal_notebooklm_sync(username: str = Depends(get_current_admin)):
try:
from services.notebooklm_podcast_generator import NotebookLMPodcastGenerator
generator = NotebookLMPodcastGenerator()
res = generator.sync_all_sources()
if res.get("success"):
return JSONResponse(content={"message": f"Синхронизация успешна! Загружено файлов: {len(res.get('uploaded_sources', []))}"})
return JSONResponse(content={"message": f"Статус: {res.get('error', 'Сессия требует настройки')}"})
except Exception as e:
return JSONResponse(status_code=500, content={"message": str(e)})
@app.post("/api/legal/generate_podcast")
async def api_legal_generate_podcast(username: str = Depends(get_current_admin)):
try:
from services.notebooklm_podcast_generator import NotebookLMPodcastGenerator
generator = NotebookLMPodcastGenerator()
if generator.is_authenticated():
res = generator.generate_audio_overview()
return JSONResponse(content={"message": "Генерация Audio Overview в NotebookLM запущена!"})
else:
# Fallback генерация сценария через Gemini 2.5 Pro
res = generator.generate_fallback_dialogue_script(
"Анализ судебных споров дома с УК и ГЖИ по отоплению, перерасчетам и отпискам прокуратуры."
)
return JSONResponse(content={"message": f"Сгенерирован сценарий подкаста через Gemini 2.5 Pro: {len(res.get('script', ''))} симв."})
except Exception as e:
return JSONResponse(status_code=500, content={"message": str(e)})
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

524
web/archive/app_full.py Normal file
View file

@ -0,0 +1,524 @@
"""
Web Admin Interface - Full Functional Admin Panel
Веб-интерфейс с полным функционалом управления ботом
"""
import logging
import json
from fastapi import FastAPI, Request, Depends, HTTPException, status, Form, Query
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, update, delete
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
import secrets
import config
import aiohttp
from database.db import AsyncSessionLocal, get_db
from database.models import (
User, Message, Poll, Ad, PaymentReminder, Schedule,
Achievement, Thank, Event, EventParticipant, InitiativeGroup,
ProfileHistory, Announcement, Service, VerificationRequest, SpyLog
)
logger = logging.getLogger(__name__)
# Приложение
app = FastAPI(
title="Домовой Бот - Полная Админ Панель",
description="Веб-интерфейс с полным функционалом",
version="2.1.3"
)
# Шаблоны
templates = Jinja2Templates(directory="web/templates")
# Статика
app.mount("/static", StaticFiles(directory="web/static"), name="static")
# Безопасность
security = HTTPBasic()
# =============================================================================
# АВТОРИЗАЦИЯ
# =============================================================================
def get_current_admin(credentials: HTTPBasicCredentials = Depends(security)):
"""Проверка админа"""
correct_username = secrets.compare_digest(credentials.username, config.WEB_ADMIN_LOGIN)
correct_password = secrets.compare_digest(credentials.password, config.WEB_ADMIN_PASSWORD)
if not (correct_username and correct_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Неверное имя пользователя или пароль",
headers={"WWW-Authenticate": "Basic"},
)
return credentials.username
# =============================================================================
# ГЛАВНАЯ СТРАНИЦА
# =============================================================================
@app.get("/", response_class=HTMLResponse)
async def dashboard(request: Request, username: str = Depends(get_current_admin)):
"""Главная страница - дашборд"""
async with AsyncSessionLocal() as session:
users_count = await session.execute(select(func.count(User.user_id)))
users_count = users_count.scalar()
messages_count = await session.execute(select(func.count(Message.id)))
messages_count = messages_count.scalar()
verified_count = await session.execute(
select(func.count(User.user_id)).where(User.verified == True)
)
verified_count = verified_count.scalar()
ig_count = await session.execute(
select(func.count(InitiativeGroup.id)).where(InitiativeGroup.is_active == True)
)
ig_count = ig_count.scalar()
return templates.TemplateResponse("dashboard.html", {
"request": request,
"username": username,
"stats": {
"users": users_count,
"messages": messages_count,
"verified": verified_count,
"ig_members": ig_count
}
})
# =============================================================================
# ПОЛЬЗОВАТЕЛИ - API
# =============================================================================
@app.get("/api/users")
async def api_get_users(
username: str = Depends(get_current_admin),
search: str = Query(None),
apartment: str = Query(None),
verified: bool = Query(None),
limit: int = Query(100)
):
"""Получить список пользователей с фильтрами"""
async with AsyncSessionLocal() as session:
stmt = select(User)
if search:
stmt = stmt.where(
(User.full_name.ilike(f"%{search}%")) |
(User.username.ilike(f"%{search}%"))
)
if apartment:
stmt = stmt.where(User.apartment.ilike(f"%{apartment}%"))
if verified is not None:
stmt = stmt.where(User.verified == verified)
stmt = stmt.order_by(User.user_id.desc()).limit(limit)
result = await session.execute(stmt)
users = list(result.scalars().all())
return {
"users": [
{
"user_id": u.user_id,
"full_name": u.full_name,
"username": u.username,
"apartment": u.apartment,
"verified": u.verified,
"rating": u.rating,
"message_count": u.message_count,
"is_admin": u.is_admin,
"spy_score": u.spy_score,
"toxicity_score": u.toxicity_score
}
for u in users
]
}
@app.post("/api/users/{user_id}/verify")
async def api_verify_user(
user_id: int,
username: str = Depends(get_current_admin)
):
"""Верифицировать пользователя"""
async with AsyncSessionLocal() as session:
user = await session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="Пользователь не найден")
user.verified = True
user.verification_date = datetime.utcnow()
await session.commit()
# Уведомить пользователя
try:
async with aiohttp.ClientSession() as http_session:
await http_session.post(
f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage",
json={
"chat_id": user_id,
"text": f"✅ <b>Верификация подтверждена!</b>\n\nТеперь у вас есть доступ ко всем функциям бота.",
"parse_mode": "HTML"
}
)
except:
pass
return {"success": True, "message": f"Пользователь {user_id} верифицирован"}
@app.post("/api/users/{user_id}/unverify")
async def api_unverify_user(
user_id: int,
reason: str = Form(None),
username: str = Depends(get_current_admin)
):
"""Снять верификацию с пользователя"""
async with AsyncSessionLocal() as session:
user = await session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="Пользователь не найден")
user.verified = False
await session.commit()
# Уведомить пользователя
try:
async with aiohttp.ClientSession() as http_session:
await http_session.post(
f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage",
json={
"chat_id": user_id,
"text": f"⚠️ <b>Верификация отозвана</b>\n\nПричина: {reason or 'Без указания причины'}\n\nУ вас есть 7 дней на повторную верификацию.",
"parse_mode": "HTML"
}
)
except:
pass
return {"success": True, "message": f"Верификация пользователя {user_id} отозвана"}
@app.post("/api/users/{user_id}/set_admin")
async def api_set_admin(
user_id: int,
is_admin: bool = Form(True),
username: str = Depends(get_current_admin)
):
"""Назначить/снять права админа"""
async with AsyncSessionLocal() as session:
user = await session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="Пользователь не найден")
user.is_admin = is_admin
await session.commit()
return {"success": True, "message": f"Пользователь {user_id} {'назначен' if is_admin else 'снят с должности'} админа"}
@app.get("/api/initiative_group")
async def api_get_ig_members(username: str = Depends(get_current_admin)):
"""Получить список участников ИГ"""
async with AsyncSessionLocal() as session:
stmt = select(InitiativeGroup).where(InitiativeGroup.is_active == True)
result = await session.execute(stmt)
members = list(result.scalars().all())
return {
"members": [
{
"user_id": m.user_id,
"role": m.role,
"joined_at": m.joined_at.isoformat(),
"name": m.user.full_name if m.user else f"user{m.user_id}",
"apartment": m.user.apartment if m.user else None
}
for m in members
]
}
@app.post("/api/initiative_group/add")
async def api_add_ig_member(
user_id: int = Form(...),
role: str = Form("member"),
username: str = Depends(get_current_admin)
):
"""Добавить в ИГ"""
async with AsyncSessionLocal() as session:
# Проверяем есть ли уже
existing = await session.get(InitiativeGroup, user_id)
if existing:
existing.is_active = True
existing.role = role
else:
member = InitiativeGroup(user_id=user_id, role=role)
session.add(member)
await session.commit()
return {"success": True, "message": f"Пользователь {user_id} добавлен в ИГ"}
@app.post("/api/initiative_group/remove")
async def api_remove_ig_member(
user_id: int = Form(...),
username: str = Depends(get_current_admin)
):
"""Удалить из ИГ"""
async with AsyncSessionLocal() as session:
member = await session.get(InitiativeGroup, user_id)
if member:
member.is_active = False
await session.commit()
return {"success": True, "message": f"Пользователь {user_id} удалён из ИГ"}
# =============================================================================
# ТЕЛЕФОНЫ - API
# =============================================================================
@app.get("/api/phones")
async def api_get_phones(username: str = Depends(get_current_admin)):
"""Получить список телефонов"""
import os
phones_file = "data/phones.json"
if os.path.exists(phones_file):
with open(phones_file, 'r', encoding='utf-8') as f:
phones_data = json.load(f)
return {"success": True, "data": phones_data}
else:
return {"success": False, "data": {}}
@app.post("/api/phones/update")
async def api_update_phones(
request: Request,
username: str = Depends(get_current_admin)
):
"""Обновить телефоны"""
import os
data = await request.json()
phones_file = "data/phones.json"
try:
with open(phones_file, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
return {"success": True, "message": "Телефоны обновлены"}
except Exception as e:
return {"success": False, "error": str(e)}
# =============================================================================
# ОБЪЯВЛЕНИЯ - API
# =============================================================================
@app.get("/api/announcements")
async def api_get_announcements(username: str = Depends(get_current_admin)):
"""Получить список объявлений"""
async with AsyncSessionLocal() as session:
stmt = select(Announcement).order_by(Announcement.created_at.desc())
result = await session.execute(stmt)
announcements = list(result.scalars().all())
return {
"announcements": [
{
"id": a.id,
"title": a.title,
"text": a.text,
"category": a.category,
"is_active": a.is_active,
"created_at": a.created_at.isoformat()
}
for a in announcements
]
}
@app.post("/api/announcements/create")
async def api_create_announcement(
title: str = Form(...),
text: str = Form(...),
category: str = Form("general"),
username: str = Depends(get_current_admin)
):
"""Создать объявление"""
async with AsyncSessionLocal() as session:
announcement = Announcement(
title=title,
text=text,
category=category,
created_by=username
)
session.add(announcement)
await session.commit()
return {"success": True, "message": "Объявление создано"}
# =============================================================================
# ВЫПОЛНЕНИЕ КОМАНД БОТА
# =============================================================================
@app.post("/api/command")
async def api_command(
request: Request,
username: str = Depends(get_current_admin)
):
"""Выполнить команду бота"""
data = await request.json()
command = data.get('command', '')
args = data.get('args', '')
if not command:
return JSONResponse({"success": False, "error": "Нет команды"})
try:
async with aiohttp.ClientSession() as http_session:
full_command = f"/{command}"
if args:
full_command += f" {args}"
result = await http_session.post(
f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage",
json={
"chat_id": config.ADMIN_CHAT_ID,
"text": full_command
}
)
return JSONResponse({
"success": True,
"message": f"Команда /{command} отправлена"
})
except Exception as e:
return JSONResponse({
"success": False,
"error": str(e)
}, status_code=500)
@app.get("/api/commands")
async def api_commands_list(username: str = Depends(get_current_admin)):
"""Список доступных команд"""
commands = {
"admin": [
{"cmd": "admin", "desc": "Админ-панель"},
{"cmd": "stats", "desc": "Статистика чата"},
{"cmd": "spies", "desc": "Подозрительные пользователи"},
{"cmd": "users", "desc": "Список жильцов"},
{"cmd": "verify", "desc": "Заявки на верификацию"},
{"cmd": "export", "desc": "Экспорт данных"},
{"cmd": "rating", "desc": "Рейтинг пользователей"},
],
"initiative_group": [
{"cmd": "ig_add", "desc": "Добавить в ИГ"},
{"cmd": "ig_remove", "desc": "Удалить из ИГ"},
{"cmd": "ig_list", "desc": "Список ИГ"},
{"cmd": "ig_stats", "desc": "Статистика ИГ"},
{"cmd": "ig_broadcast", "desc": "Рассылка для ИГ"},
],
"events": [
{"cmd": "event_add", "desc": "Добавить событие"},
{"cmd": "events", "desc": "Список событий"},
{"cmd": "participants", "desc": "Участники события"},
],
"security": [
{"cmd": "unverify", "desc": "Отозвать верификацию"},
{"cmd": "unverified_list", "desc": "Список не верифицированных"},
{"cmd": "ban", "desc": "Забанить пользователя"},
{"cmd": "unban", "desc": "Разбанить"},
],
"phones": [
{"cmd": "add_phone", "desc": "Добавить телефон"},
{"cmd": "edit_phone", "desc": "Редактировать телефон"},
{"cmd": "remove_phone", "desc": "Удалить телефон"},
]
}
return JSONResponse({"commands": commands})
# =============================================================================
# СТРАНИЦЫ
# =============================================================================
@app.get("/users", response_class=HTMLResponse)
async def users_page(request: Request, username: str = Depends(get_current_admin)):
"""Страница пользователей"""
return templates.TemplateResponse("users.html", {
"request": request,
"username": username
})
@app.get("/initiative_group", response_class=HTMLResponse)
async def ig_page(request: Request, username: str = Depends(get_current_admin)):
"""Страница ИГ"""
return templates.TemplateResponse("initiative_group.html", {
"request": request,
"username": username
})
@app.get("/phones", response_class=HTMLResponse)
async def phones_page(request: Request, username: str = Depends(get_current_admin)):
"""Страница телефонов"""
return templates.TemplateResponse("phones.html", {
"request": request,
"username": username
})
@app.get("/announcements", response_class=HTMLResponse)
async def announcements_page(request: Request, username: str = Depends(get_current_admin)):
"""Страница объявлений"""
return templates.TemplateResponse("announcements.html", {
"request": request,
"username": username
})
@app.get("/commands", response_class=HTMLResponse)
async def commands_page(request: Request, username: str = Depends(get_current_admin)):
"""Страница команд"""
return templates.TemplateResponse("commands.html", {
"request": request,
"username": username
})
# =============================================================================
# ЗАПУСК
# =============================================================================
def run_web_server():
"""Запустить веб-сервер"""
import uvicorn
uvicorn.run(
app,
host=config.WEB_ADMIN_HOST,
port=config.WEB_ADMIN_PORT,
log_level="info"
)
if __name__ == "__main__":
run_web_server()

1547
web/archive/app_reference.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -6,192 +6,278 @@
<title>{% block title %}LKM37 CORE{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<link href="/static/css/matrix.css" rel="stylesheet">
<style>
.sidebar { min-height: 100vh; position: sticky; top: 0; overflow-y: auto; }
.content { padding: 2rem; }
:root {
--bg-main: #f4f4f4;
--bg-panel: #ffffff;
--bg-header: #ece9d8;
--border-color: #707680;
--border-light: #cccccc;
--text-main: #111111;
--text-muted: #555555;
--link-color: #0000cc;
--link-hover: #cc0000;
--badge-bg: #e8e8e8;
}
body {
background-color: var(--bg-main);
color: var(--text-main);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 13px;
line-height: 1.45;
margin: 0;
padding: 0;
}
a {
color: var(--link-color);
text-decoration: none;
}
a:hover {
color: var(--link-hover);
text-decoration: underline;
}
.layout-container {
display: flex;
min-height: 100vh;
}
.sidebar {
width: 260px;
background-color: var(--bg-panel);
border-right: 1px solid var(--border-color);
padding: 12px;
flex-shrink: 0;
}
.sidebar-header {
background-color: var(--bg-header);
border: 1px solid var(--border-color);
padding: 8px 12px;
margin-bottom: 12px;
text-align: center;
}
.sidebar-header h4 {
font-size: 15px;
font-weight: bold;
margin: 0;
letter-spacing: 0.5px;
}
.sidebar-header small {
font-size: 11px;
color: var(--text-muted);
}
.nav-section-title {
font-size: 11px;
font-weight: bold;
color: var(--text-muted);
text-transform: uppercase;
margin-top: 10px;
margin-bottom: 4px;
padding-left: 6px;
border-left: 3px solid var(--border-color);
}
.nav-link {
display: block;
padding: 4px 8px;
color: var(--text-main) !important;
border: 1px solid transparent;
border-radius: 0;
margin-bottom: 2px;
font-size: 12px;
}
.nav-link:hover {
background-color: #eaeaea;
border-color: var(--border-light);
text-decoration: none;
}
.nav-link.active {
background-color: var(--bg-header);
border-color: var(--border-color);
font-weight: bold;
}
.nav-link i {
margin-right: 6px;
font-size: 13px;
}
.main-content {
flex-grow: 1;
padding: 16px 24px;
overflow-x: auto;
}
.card, .panel {
background-color: var(--bg-panel);
border: 1px solid var(--border-color);
border-radius: 0;
margin-bottom: 16px;
}
.card-header, .panel-header {
background-color: var(--bg-header);
border-bottom: 1px solid var(--border-color);
padding: 6px 12px;
font-weight: bold;
font-size: 13px;
border-radius: 0 !important;
}
.card-body, .panel-body {
padding: 12px;
}
table {
border-collapse: collapse;
width: 100%;
background-color: var(--bg-panel);
font-size: 12px;
}
table th, table td {
border: 1px solid var(--border-light);
padding: 5px 8px;
text-align: left;
}
table th {
background-color: var(--bg-header);
border-color: var(--border-color);
font-weight: bold;
}
table tr:hover {
background-color: #fafafa;
}
.btn-unix {
display: inline-block;
font-family: inherit;
font-size: 12px;
padding: 3px 8px;
background-color: #e8e8e8;
color: #000000;
border: 1px solid var(--border-color);
border-radius: 0;
cursor: pointer;
text-decoration: none;
}
.btn-unix:hover {
background-color: #d8d8d8;
color: #000000;
text-decoration: none;
}
.btn-unix-primary {
background-color: #002b49;
color: #ffffff;
border-color: #001828;
}
.btn-unix-primary:hover {
background-color: #003d66;
color: #ffffff;
}
pre, code {
font-family: inherit;
background-color: #e8e8e8;
border: 1px solid var(--border-light);
padding: 2px 4px;
font-size: 12px;
}
pre {
padding: 8px;
overflow-x: auto;
}
.badge-status {
display: inline-block;
padding: 2px 5px;
border: 1px solid var(--border-color);
background-color: var(--badge-bg);
font-size: 11px;
}
.badge-success { background-color: #e2f0d9; border-color: #70ad47; color: #385723; }
.badge-warning { background-color: #fff2cc; border-color: #ffc000; color: #806000; }
.badge-danger { background-color: #fce4d6; border-color: #ed7d31; color: #c65911; }
{% block extra_style %}{% endblock %}
</style>
</head>
<body>
<div id="matrix-canvas-container" style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: -1; opacity: 0.1;">
<canvas id="matrix-canvas"></canvas>
<div class="layout-container">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-header">
<h4>МКД ЛИВАНОВА 37</h4>
<small>СИСТЕМА УПРАВЛЕНИЯ & ЯДЕРНЫЙ ЩИТ</small>
</div>
<div class="container-fluid">
<div class="row">
<!-- Sidebar (ВОССТАНОВЛЕННОЕ ПОЛНОЕ МЕНЮ) -->
<div class="col-md-2 sidebar p-0">
<div class="p-3 text-center border-bottom" style="border-color: var(--panel-border) !important;">
<h4 class="mb-0" style="color: var(--color-success); text-shadow: 0 0 15px var(--color-success-glow); font-weight: 700; letter-spacing: 1px;">LKM37</h4>
<small class="text-success opacity-50" style="font-family: var(--font-mono); font-size: 0.7rem; letter-spacing: 0.5px;">CORE SYSTEM v4.5</small>
</div>
<nav class="nav flex-column mt-3">
<nav>
<a class="nav-link {% block nav_dashboard %}{% endblock %}" href="/">
<i class="bi bi-terminal"></i> ДАШБОРД
<i class="bi bi-terminal"></i> Дашборд
</a>
<div class="mt-3 px-3"><small class="text-success opacity-50 text-uppercase" style="font-size: 0.6em;">Жильцы</small></div>
<a class="nav-link {% block nav_users %}{% endblock %}" href="/users">
<i class="bi bi-people"></i> ЖИЛЬЦЫ
</a>
<a class="nav-link {% block nav_verification %}{% endblock %}" href="/verification">
<i class="bi bi-shield-lock"></i> ВЕРИФИКАЦИЯ
</a>
<a class="nav-link {% block nav_phones %}{% endblock %}" href="/phones">
<i class="bi bi-telephone"></i> ТЕЛЕФОНЫ
</a>
<div class="mt-3 px-3"><small class="text-success opacity-50 text-uppercase" style="font-size: 0.6em;">Контент</small></div>
<a class="nav-link {% block nav_broadcast %}{% endblock %}" href="/broadcast">
<i class="bi bi-broadcast"></i> РАССЫЛКА
</a>
<a class="nav-link {% block nav_scheduled_posts %}{% endblock %}" href="/scheduled_posts">
<i class="bi bi-clock-history"></i> ПОСТЫ
</a>
<a class="nav-link {% block nav_digests %}{% endblock %}" href="/digests">
<i class="bi bi-file-earmark-text"></i> ДАЙДЖЕСТЫ
</a>
<a class="nav-link {% block nav_files %}{% endblock %}" href="/files">
<i class="bi bi-folder2-open"></i> ФАЙЛЫ
</a>
<div class="mt-3 px-3"><small class="text-success opacity-50 text-uppercase" style="font-size: 0.6em;">Сервисы</small></div>
<a class="nav-link {% block nav_schedules %}{% endblock %}" href="/schedules">
<i class="bi bi-calendar-event"></i> ГРАФИКИ
</a>
<a class="nav-link {% block nav_ads %}{% endblock %}" href="/ads">
<i class="bi bi-shop"></i> ОБЪЯВЛЕНИЯ
</a>
<a class="nav-link {% block nav_polls %}{% endblock %}" href="/polls">
<i class="bi bi-check2-square"></i> ОПРОСЫ
</a>
<a class="nav-link {% block nav_events %}{% endblock %}" href="/events">
<i class="bi bi-calendar3"></i> СОБЫТИЯ
</a>
<div class="mt-3 px-3"><small class="text-success opacity-50 text-uppercase" style="font-size: 0.6em;">Юрист</small></div>
<a class="nav-link {% block nav_sudrf %}{% endblock %}" href="/sudrf">
<i class="bi bi-bank text-danger"></i> ЖАТВА ГАС
</a>
<div class="mt-3 px-3"><small class="text-success opacity-50 text-uppercase" style="font-size: 0.6em;">Система</small></div>
<a class="nav-link {% block nav_infra %}{% endblock %}" href="/infra">
<i class="bi bi-cpu"></i> ИНФРАСТРУКТУРА
</a>
<a class="nav-link {% block nav_export %}{% endblock %}" href="/export">
<i class="bi bi-download"></i> ЭКСПОРТ
<div class="nav-section-title">Юридический щит</div>
<a class="nav-link {% block nav_legal %}{% endblock %}" href="/legal">
<i class="bi bi-shield-check"></i> Законы РФ & ИИ-Юрист
</a>
<a class="nav-link {% block nav_emails %}{% endblock %}" href="/emails">
<i class="bi bi-envelope-at"></i> ПОЧТА
<i class="bi bi-envelope-check"></i> Реестр писем & ЭЦП
</a>
<a class="nav-link {% block nav_sudrf %}{% endblock %}" href="/sudrf">
<i class="bi bi-bank"></i> Суды ГАС Правосудие
</a>
<div class="nav-section-title">Жильцы & Доступ</div>
<a class="nav-link {% block nav_users %}{% endblock %}" href="/users">
<i class="bi bi-people"></i> Жильцы (AI доступ)
</a>
<a class="nav-link {% block nav_verification %}{% endblock %}" href="/verification">
<i class="bi bi-patch-check"></i> Верификация
</a>
<a class="nav-link {% block nav_phones %}{% endblock %}" href="/phones">
<i class="bi bi-telephone"></i> Телефоны
</a>
<div class="nav-section-title">Коммуникация</div>
<a class="nav-link {% block nav_broadcast %}{% endblock %}" href="/broadcast">
<i class="bi bi-broadcast"></i> Оповещения (Zero-Spam)
</a>
<a class="nav-link {% block nav_scheduled_posts %}{% endblock %}" href="/scheduled_posts">
<i class="bi bi-clock-history"></i> Запланированные посты
</a>
<a class="nav-link {% block nav_digests %}{% endblock %}" href="/digests">
<i class="bi bi-newspaper"></i> Дайджесты
</a>
<div class="nav-section-title">Сервисы & Инфра</div>
<a class="nav-link {% block nav_files %}{% endblock %}" href="/files">
<i class="bi bi-folder2-open"></i> Файлы и архивы
</a>
<a class="nav-link {% block nav_schedules %}{% endblock %}" href="/schedules">
<i class="bi bi-calendar3"></i> Графики
</a>
<a class="nav-link {% block nav_ads %}{% endblock %}" href="/ads">
<i class="bi bi-shop"></i> Объявления
</a>
<a class="nav-link {% block nav_polls %}{% endblock %}" href="/polls">
<i class="bi bi-check2-square"></i> Опросы
</a>
<a class="nav-link {% block nav_infra %}{% endblock %}" href="/infra">
<i class="bi bi-cpu"></i> Инфраструктура
</a>
<a class="nav-link {% block nav_export %}{% endblock %}" href="/export">
<i class="bi bi-download"></i> Экспорт базы
</a>
</nav>
</div>
</aside>
<!-- Main Content -->
<div class="col-md-10 content">
<main class="main-content">
{% block content %}{% endblock %}
</div>
</div>
</main>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
const canvas = document.getElementById('matrix-canvas');
const ctx = canvas.getContext('2d');
let width = canvas.width = window.innerWidth;
let height = canvas.height = window.innerHeight;
const particles = [];
const properties = {
bgColor: '#06060c',
particleColor: 'rgba(138, 43, 226, 0.25)',
lineColor: 'rgba(0, 255, 204, 0.05)',
particleRadius: 2.5,
particleCount: 70,
maxVelocity: 0.4,
lineLength: 140
};
window.addEventListener('resize', function() {
width = canvas.width = window.innerWidth;
height = canvas.height = window.innerHeight;
});
class Particle {
constructor() {
this.x = Math.random() * width;
this.y = Math.random() * height;
this.velocityPosition = {
x: (Math.random() - 0.5) * properties.maxVelocity,
y: (Math.random() - 0.5) * properties.maxVelocity
}
}
position() {
if (this.x + this.velocityPosition.x > width || this.x + this.velocityPosition.x < 0) {
this.velocityPosition.x = -this.velocityPosition.x;
}
if (this.y + this.velocityPosition.y > height || this.y + this.velocityPosition.y < 0) {
this.velocityPosition.y = -this.velocityPosition.y;
}
this.x += this.velocityPosition.x;
this.y += this.velocityPosition.y;
}
reDraw() {
ctx.beginPath();
ctx.arc(this.x, this.y, properties.particleRadius, 0, Math.PI * 2);
ctx.closePath();
ctx.fillStyle = properties.particleColor;
ctx.fill();
}
}
function drawLines() {
let x1, y1, x2, y2, length, opacity;
for (let i = 0; i < particles.length; i++) {
for (let j = i + 1; j < particles.length; j++) {
x1 = particles[i].x;
y1 = particles[i].y;
x2 = particles[j].x;
y2 = particles[j].y;
length = Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
if (length < properties.lineLength) {
opacity = 1 - (length / properties.lineLength);
ctx.strokeStyle = `rgba(0, 255, 204, ${opacity * 0.06})`;
ctx.lineWidth = '0.5';
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.closePath();
ctx.stroke();
}
}
}
}
function init() {
for (let i = 0; i < properties.particleCount; i++) {
particles.push(new Particle());
}
loop();
}
function loop() {
ctx.fillStyle = properties.bgColor;
ctx.fillRect(0, 0, width, height);
for (let i = 0; i < particles.length; i++) {
particles[i].position();
particles[i].reDraw();
}
drawLines();
requestAnimationFrame(loop);
}
init();
</script>
{% block extra_script %}{% endblock %}
{% block scripts %}{% endblock %}
</body>
</html>

217
web/templates/legal.html Normal file
View file

@ -0,0 +1,217 @@
{% extends "base.html" %}
{% block title %}Нормативно-правовая база & ИИ-Юрист | LKM37{% endblock %}
{% block nav_legal %}active{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h3 class="m-0" style="font-size: 18px; font-weight: bold;">НОРМАТИВНО-ПРАВОВАЯ БАЗА РФ & ИИ-ЮРИСТ</h3>
<div>
<button class="btn-unix btn-unix-primary" onclick="syncNotebookLM()">
<i class="bi bi-arrow-repeat"></i> Синхронизация с NotebookLM
</button>
<button class="btn-unix" onclick="generatePodcast()">
<i class="bi bi-mic"></i> Сгенерировать подкаст (Audio Overview)
</button>
</div>
</div>
<div class="row">
<!-- Левая колонка: Реестр полных законов РФ -->
<div class="col-md-7">
<div class="panel">
<div class="panel-header d-flex justify-content-between align-items-center">
<span>ПОЛНЫЕ ОФИЦИАЛЬНЫЕ КОДЕКСЫ И НОРМАТИВНЫЕ АКТЫ РФ (100% ТЕКСТ)</span>
<span class="badge-status badge-success">{{ laws|length }} документов</span>
</div>
<div class="panel-body p-0">
<table>
<thead>
<tr>
<th style="width: 45px;">№</th>
<th>Наименование нормативно-правового акта</th>
<th style="width: 75px;">Стр.</th>
<th style="width: 85px;">Размер</th>
<th style="width: 100px;">Действие</th>
</tr>
</thead>
<tbody>
{% for law in laws %}
<tr>
<td>{{ loop.index }}</td>
<td>
<strong>{{ law.title }}</strong><br>
<small style="color: var(--text-muted);">{{ law.filename }}</small>
</td>
<td>{{ law.pages }}</td>
<td>{{ law.size_formatted }}</td>
<td>
<a href="/api/legal/download/{{ law.filename }}" class="btn-unix" download>
<i class="bi bi-download"></i> PDF
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Карточка томов переписки -->
<div class="panel">
<div class="panel-header d-flex justify-content-between align-items-center">
<span>СВОДНЫЕ ТОМА ПЕРЕПИСКИ С ГОСОРГАНАМИ & ШТАМПЫ ЭЦП</span>
<span class="badge-status badge-success">{{ mega_volumes|length }} тома</span>
</div>
<div class="panel-body p-0">
<table>
<thead>
<tr>
<th>Том переписки</th>
<th>Размер</th>
<th>Оглавление TOC</th>
<th>Действие</th>
</tr>
</thead>
<tbody>
{% for vol in mega_volumes %}
<tr>
<td><strong>{{ vol.name }}</strong></td>
<td>{{ vol.size_formatted }}</td>
<td><span class="badge-status badge-success">Вшито в PDF</span></td>
<td>
<a href="/api/legal/download_volume/{{ vol.name }}" class="btn-unix" download>
<i class="bi bi-download"></i> Скачать
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Правая колонка: ИИ-Консультант и Генератор Подкастов -->
<div class="col-md-5">
<!-- Интерактивный AI-Юрист -->
<div class="panel">
<div class="panel-header">
<i class="bi bi-cpu"></i> АВТОНОМНЫЙ ИИ-КОНСУЛЬТАНТ (GEMINI 2.5)
</div>
<div class="panel-body">
<p style="font-size: 11px; color: var(--text-muted); margin-bottom: 8px;">
Контекст: Полный свод ЖК РФ, ГК РФ, 59-ФЗ, ПП 354, ПП 491, Госстрой 170 + 610 писем дома с верифицированными ЭЦП.
</p>
<div class="mb-2">
<label style="font-weight: bold; font-size: 11px;">Вопрос / Фабула ситуации:</label>
<textarea id="ai-query" class="form-control" rows="4" style="font-family: inherit; font-size: 12px; border: 1px solid var(--border-color); border-radius: 0;" placeholder="Например: УК отказалась делать перерасчет за отопление в подъезде 4, утверждая что перерыв не превышал допустимой нормы. На какие пункты ПП 354 и Госстроя 170 сослаться в претензии?"></textarea>
</div>
<div class="d-flex justify-content-between align-items-center mb-2">
<div>
<input type="checkbox" id="ai-deep-mode">
<label for="ai-deep-mode" style="font-size: 11px; cursor: pointer;">Глубокий анализ (Gemini 2.5 Pro)</label>
</div>
<button class="btn-unix btn-unix-primary" onclick="askAiLawyer()">
<i class="bi bi-send"></i> Сформировать позицию
</button>
</div>
<div id="ai-response-box" style="display: none; margin-top: 12px; border-top: 1px dashed var(--border-color); padding-top: 10px;">
<div class="d-flex justify-content-between align-items-center mb-1">
<strong style="font-size: 11px;">ОТВЕТ ИИ-ЮРИСТА:</strong>
<span id="ai-model-tag" class="badge-status badge-success">Flash</span>
</div>
<div id="ai-response-text" style="background-color: #fafafa; border: 1px solid var(--border-light); padding: 8px; font-size: 11px; white-space: pre-wrap; max-height: 400px; overflow-y: auto;"></div>
</div>
</div>
</div>
<!-- Аудио-подкасты NotebookLM -->
<div class="panel">
<div class="panel-header">
<i class="bi bi-soundwave"></i> АУДИО-ПОДКАСТЫ ДОМА (AUDIO OVERVIEWS)
</div>
<div class="panel-body">
<p style="font-size: 11px; color: var(--text-muted); margin-bottom: 8px;">
Автоматическая генерация аналитических подкастов диалога двух экспертов по делам дома.
</p>
<div id="podcast-status-box">
{% if latest_podcast %}
<div class="mb-2">
<strong>Последний подкаст:</strong> {{ latest_podcast.name }}<br>
<small style="color: var(--text-muted);">Размер: {{ latest_podcast.size }} | Дата: {{ latest_podcast.date }}</small>
</div>
<audio controls style="width: 100%; margin-bottom: 8px;">
<source src="/api/legal/podcasts/{{ latest_podcast.name }}" type="audio/mp4">
Ваш браузер не поддерживает аудио.
</audio>
{% else %}
<p style="font-size: 11px; font-style: italic; color: var(--text-muted);">
Подкасты еще не генерировались. Нажмите кнопку генерации выше.
</p>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
async function askAiLawyer() {
const query = document.getElementById('ai-query').value.trim();
if (!query) {
alert('Пожалуйста, введите текст вопроса');
return;
}
const deep = document.getElementById('ai-deep-mode').checked;
const resBox = document.getElementById('ai-response-box');
const resText = document.getElementById('ai-response-text');
const tag = document.getElementById('ai-model-tag');
resBox.style.display = 'block';
resText.innerText = 'Анализ правовой базы и судебной практики... Пожалуйста, подождите.';
tag.innerText = deep ? 'Gemini 2.5 Pro' : 'Gemini 2.5 Flash';
try {
const resp = await fetch('/api/legal/ask_ai', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({query: query, deep: deep})
});
const data = await resp.json();
if (data.success) {
resText.innerText = data.answer;
} else {
resText.innerText = 'Ошибка: ' + (data.error || 'Неизвестная ошибка');
}
} catch (e) {
resText.innerText = 'Сетевая ошибка обращения к ИИ-Юристу: ' + e;
}
}
async function syncNotebookLM() {
if (!confirm('Запустить полную синхронизацию законов и томов переписки с Google NotebookLM?')) return;
try {
const resp = await fetch('/api/legal/notebooklm_sync', {method: 'POST'});
const data = await resp.json();
alert(data.message || 'Синхронизация завершена!');
} catch (e) {
alert('Ошибка запуска синхронизации: ' + e);
}
}
async function generatePodcast() {
if (!confirm('Запустить генерацию Audio Overview подкаста в NotebookLM?')) return;
try {
const resp = await fetch('/api/legal/generate_podcast', {method: 'POST'});
const data = await resp.json();
alert(data.message || 'Задача генерации подкаста запущена!');
} catch (e) {
alert('Ошибка запуска генерации подкаста: ' + e);
}
}
</script>
{% endblock %}