feat(legal): прямой RAG через NotebookLM (Разбор ответов строго по ЖК РФ) без галлюцинаций
This commit is contained in:
parent
0dcea20739
commit
401fb3b32a
6 changed files with 93 additions and 29 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,6 +4,7 @@ __pycache__/
|
|||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv-ai/
|
||||
venv/
|
||||
venv-google/
|
||||
env/
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""Определяет роль пользователя в системе."""
|
||||
|
|
|
|||
|
|
@ -26,3 +26,4 @@ google-auth-oauthlib
|
|||
|
||||
|
||||
edge-tts>=7.0.0
|
||||
notebooklm-py>=0.8.5
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
Loading…
Reference in a new issue