feat(legal): прямой RAG через NotebookLM (Разбор ответов строго по ЖК РФ) без галлюцинаций

This commit is contained in:
Admin 2026-10-13 12:13:53 +04:00
parent 0dcea20739
commit 401fb3b32a
6 changed files with 93 additions and 29 deletions

1
.gitignore vendored
View file

@ -4,6 +4,7 @@ __pycache__/
*$py.class
*.so
.Python
venv-ai/
venv/
venv-google/
env/

View file

@ -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:

View file

@ -44,22 +44,46 @@ def format_telegram_html(text: str) -> str:
# *курсив* -> <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)
safe = re.sub(r'\[(.*?)\]\((https?://.*?)\)', 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}"
"""Отправляет длинный ответ аккуратными частями с защитой от разрыва тегов."""
# Разбиваем исходный текст по параграфам
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"<i>(Продолжение, часть {idx}):</i>\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"<i>(Продолжение {idx}/{len(chunks)}):</i>\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:
"""Определяет роль пользователя в системе."""

View file

@ -26,3 +26,4 @@ google-auth-oauthlib
edge-tts>=7.0.0
notebooklm-py>=0.8.5

View file

@ -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
}

View file

@ -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__":