From 401fb3b32ad59d4e7b6f854ad82d4193665b8aca Mon Sep 17 00:00:00 2001 From: Admin Date: Tue, 13 Oct 2026 12:13:53 +0400 Subject: [PATCH] =?UTF-8?q?feat(legal):=20=D0=BF=D1=80=D1=8F=D0=BC=D0=BE?= =?UTF-8?q?=D0=B9=20RAG=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20NotebookLM=20(?= =?UTF-8?q?=D0=A0=D0=B0=D0=B7=D0=B1=D0=BE=D1=80=20=D0=BE=D1=82=D0=B2=D0=B5?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=20=D1=81=D1=82=D1=80=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=96=D0=9A=20=D0=A0=D0=A4)=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20=D0=B3=D0=B0=D0=BB=D0=BB=D1=8E=D1=86=D0=B8=D0=BD=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + docker-compose.yml | 4 ++ handlers/legal_advisor.py | 46 +++++++++++++---- requirements.txt | 1 + services/ai_legal_engine.py | 64 ++++++++++++++++++------ services/notebooklm_podcast_generator.py | 6 +-- 6 files changed, 93 insertions(+), 29 deletions(-) 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__":