domovoy_bot/services/notebooklm_podcast_generator.py

206 lines
10 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
"""
Сервис интеграции с 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])