diff --git a/.gitignore b/.gitignore
index 74d0186..57102b3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@ __pycache__/
*$py.class
*.so
.Python
+venv-ai/
venv/
venv-google/
env/
diff --git a/docker-compose.yml b/docker-compose.yml
index 657c423..f84617a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -21,6 +21,8 @@ services:
- ./services:/app/services
- ../libs/swarmlib:/app/swarmlib:ro
- /home/matrixhasyou/domovoy_google_creds.json:/home/matrixhasyou/domovoy_google_creds.json:ro
+ - /home/matrixhasyou/.notebooklm:/root/.notebooklm:rw
+ - ./venv-ai:/app/venv-ai:ro
logging:
driver: "json-file"
options:
@@ -51,6 +53,8 @@ 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
+ - /home/matrixhasyou/.notebooklm:/root/.notebooklm:rw
+ - ./venv-ai:/app/venv-ai:ro
logging:
driver: "json-file"
options:
diff --git a/handlers/legal_advisor.py b/handlers/legal_advisor.py
index 74ce5eb..af94ca6 100644
--- a/handlers/legal_advisor.py
+++ b/handlers/legal_advisor.py
@@ -44,22 +44,46 @@ def format_telegram_html(text: str) -> str:
# *курсив* -> курсив
safe = re.sub(r'\*(.*?)\*', r'\1', safe, flags=re.DOTALL)
# [ссылка](url) -> ссылка
- safe = re.sub(r'\[(.*?)\]\((.*?)\)', r'\1', safe, flags=re.DOTALL)
+ safe = re.sub(r'\[(.*?)\]\((https?://.*?)\)', r'\1', 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}"
+ """Отправляет длинный ответ аккуратными частями с защитой от разрыва тегов."""
+ # Разбиваем исходный текст по параграфам
+ paragraphs = body.split("\n\n")
+ chunks = []
+ current_chunk = []
+ current_len = 0
- if len(full_text) <= 4000:
- await message.answer(full_text, parse_mode='HTML')
- return
+ 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
- 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"(Продолжение, часть {idx}):\n\n{part}", parse_mode='HTML')
+ 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"(Продолжение {idx}/{len(chunks)}):\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:
"""Определяет роль пользователя в системе."""
diff --git a/requirements.txt b/requirements.txt
index 70f3b0a..809fad5 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -26,3 +26,4 @@ google-auth-oauthlib
edge-tts>=7.0.0
+notebooklm-py>=0.8.5
diff --git a/services/ai_legal_engine.py b/services/ai_legal_engine.py
index cbc6e4c..9ba2a60 100644
--- a/services/ai_legal_engine.py
+++ b/services/ai_legal_engine.py
@@ -113,16 +113,39 @@ class AILegalEngine:
async def query_notebooklm(self, prompt: str) -> dict:
"""Прямой запрос к блокноту NotebookLM «Разбор ответов строго по ЖК РФ»."""
+ import shutil
notebook_id = os.getenv("NOTEBOOKLM_ID", "00ed7d92-1ab2-4159-ac72-1d0c4be21377")
- nlm_bins = ["/home/matrixhasyou/.local/bin/nlm", "/usr/local/bin/nlm", "nlm"]
- nlm_bin = next((b for b in nlm_bins if os.path.exists(b)), "nlm")
+ candidates = [
+ "/usr/local/bin/notebooklm",
+ shutil.which("notebooklm"),
+ "/home/matrixhasyou/swarm-services/domovoy-bot/venv-ai/bin/notebooklm",
+ "notebooklm"
+ ]
+ nb_bin = None
+ for b in candidates:
+ if b and os.path.exists(b) and os.access(b, os.X_OK):
+ # Проверим, не сломан ли шебанг
+ try:
+ with open(b, "rb") as f:
+ first_line = f.readline()
+ if first_line.startswith(b"#!"):
+ interp = first_line[2:].strip().decode("utf-8", errors="ignore")
+ if not os.path.exists(interp):
+ continue
+ except Exception:
+ pass
+ nb_bin = b
+ break
+ if not nb_bin:
+ nb_bin = "/usr/local/bin/notebooklm" if os.path.exists("/usr/local/bin/notebooklm") else "notebooklm"
cmd = [
- nlm_bin, "query", "notebook",
- notebook_id,
+ nb_bin, "ask",
+ "-n", notebook_id,
prompt
]
env = os.environ.copy()
+ env["HOME"] = "/root" if os.path.exists("/root/.notebooklm") else os.environ.get("HOME", "/root")
env["SSL_CERT_FILE"] = "/etc/ssl/certs/ca-certificates.crt"
try:
@@ -132,26 +155,35 @@ class AILegalEngine:
stderr=asyncio.subprocess.PIPE,
env=env
)
- stdout, stderr = await proc.communicate()
+ try:
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=90.0)
+ except asyncio.TimeoutError:
+ try: proc.kill()
+ except: pass
+ return {"success": False, "error": "Таймаут обращения к NotebookLM (более 90 сек)", "source": "notebooklm"}
+
out_str = stdout.decode('utf-8', errors='ignore').strip()
err_str = stderr.decode('utf-8', errors='ignore').strip()
- if proc.returncode == 0 and out_str and "Authentication Error" not in out_str and "ConnectError" not in out_str:
- # Очистка предупреждений об апдейте nlm
- if "Update available:" in out_str:
- out_str = out_str.split("Update available:")[0].strip()
- return {"success": True, "text": out_str, "source": "notebooklm"}
+ if proc.returncode == 0 and out_str and "Authentication Error" not in out_str and "Not logged in" not in out_str:
+ lines = [l for l in out_str.splitlines() if not l.startswith("Continuing conversation") and not l.startswith("Resumed conversation")]
+ cleaned = "\n".join(lines).strip()
+ if cleaned.startswith("Answer:"):
+ cleaned = cleaned[len("Answer:"):].strip()
+ return {"success": True, "text": cleaned, "source": "notebooklm"}
else:
+ log.warning(f"NotebookLM non-zero or auth err: code={proc.returncode}, out={out_str[:200]}, err={err_str[:200]}")
return {"success": False, "error": err_str or out_str, "source": "notebooklm"}
except Exception as e:
+ log.error(f"NotebookLM exec exception: {e}")
return {"success": False, "error": str(e), "source": "notebooklm"}
async def query(self, prompt: str, role: str = "resident", audio_path: str = None) -> dict:
- """Основной метод запроса: NotebookLM (первый приоритет) -> Gemini (fallback)."""
+ """Основной метод запроса: NotebookLM (первый безусловный приоритет) -> Gemini (fallback/аудио)."""
start_time = datetime.now()
- # Если вопрос от Админа и нет аудио — пробуем NotebookLM в первую очередь!
- if role in ("admin", "lawyer") and not audio_path and prompt:
+ # 1. Текстовые вопросы ВСЕХ пользователей идут строго в блокнот «Разбор ответов строго по ЖК РФ»!
+ if not audio_path and prompt:
nlm_res = await self.query_notebooklm(prompt)
if nlm_res.get("success"):
latency = (datetime.now() - start_time).total_seconds()
@@ -163,12 +195,14 @@ class AILegalEngine:
"latency_sec": round(latency, 2),
"model": "NotebookLM: Разбор ответов строго по ЖК РФ"
}
+ else:
+ log.warning(f"NotebookLM query failed: {nlm_res.get('error')}. Falling back to Gemini.")
- # Резервный контур через Gemini API (если сессия блокнота требует продления или жилец)
+ # 2. Резервный контур через Gemini API (для аудиосообщений либо если блокнот временно недоступен)
if not self.api_key:
return {
"success": False,
- "text": "ИИ-модуль временно недоступен. Проверьте API ключ.",
+ "text": "ИИ-модуль временно недоступен. Проверьте подключение.",
"role": role,
"latency_sec": 0.0
}
diff --git a/services/notebooklm_podcast_generator.py b/services/notebooklm_podcast_generator.py
index eef5f60..ce2d560 100644
--- a/services/notebooklm_podcast_generator.py
+++ b/services/notebooklm_podcast_generator.py
@@ -25,7 +25,7 @@ NOTEBOOKLM_BIN = "/home/matrixhasyou/swarm-services/domovoy-bot/venv-ai/bin/note
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 (Суды, Прокуратура, ЖКХ)"
+DEFAULT_NOTEBOOK_NAME = "Разбор ответов строго по ЖК РФ"
class NotebookLMPodcastGenerator:
def __init__(self, notebook_name: str = DEFAULT_NOTEBOOK_NAME):
@@ -183,7 +183,7 @@ class NotebookLMPodcastGenerator:
В конце — четкие выводы и 3 практических шага для жителей дома.
"""
response = client.models.generate_content(
- model="gemini-2.5-pro",
+ model="gemini-2.5-flash",
contents=prompt,
config=types.GenerateContentConfig(
temperature=0.4
@@ -192,7 +192,7 @@ class NotebookLMPodcastGenerator:
return {
"success": True,
"script": response.text,
- "engine": "gemini-2.5-pro-fallback"
+ "engine": "gemini-2.5-flash-fallback"
}
if __name__ == "__main__":