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
|
*$py.class
|
||||||
*.so
|
*.so
|
||||||
.Python
|
.Python
|
||||||
|
venv-ai/
|
||||||
venv/
|
venv/
|
||||||
venv-google/
|
venv-google/
|
||||||
env/
|
env/
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ services:
|
||||||
- ./services:/app/services
|
- ./services:/app/services
|
||||||
- ../libs/swarmlib:/app/swarmlib:ro
|
- ../libs/swarmlib:/app/swarmlib:ro
|
||||||
- /home/matrixhasyou/domovoy_google_creds.json:/home/matrixhasyou/domovoy_google_creds.json: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:
|
logging:
|
||||||
driver: "json-file"
|
driver: "json-file"
|
||||||
options:
|
options:
|
||||||
|
|
@ -51,6 +53,8 @@ services:
|
||||||
- /home/matrixhasyou/mutt:/home/matrixhasyou/mutt:ro
|
- /home/matrixhasyou/mutt:/home/matrixhasyou/mutt:ro
|
||||||
- ../libs/swarmlib:/app/swarmlib:ro
|
- ../libs/swarmlib:/app/swarmlib:ro
|
||||||
- /home/matrixhasyou/domovoy_google_creds.json:/home/matrixhasyou/domovoy_google_creds.json: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:
|
logging:
|
||||||
driver: "json-file"
|
driver: "json-file"
|
||||||
options:
|
options:
|
||||||
|
|
|
||||||
|
|
@ -44,22 +44,46 @@ def format_telegram_html(text: str) -> str:
|
||||||
# *курсив* -> <i>курсив</i>
|
# *курсив* -> <i>курсив</i>
|
||||||
safe = re.sub(r'\*(.*?)\*', r'<i>\1</i>', safe, flags=re.DOTALL)
|
safe = re.sub(r'\*(.*?)\*', r'<i>\1</i>', safe, flags=re.DOTALL)
|
||||||
# [ссылка](url) -> <a href="url">ссылка</a>
|
# [ссылка](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
|
return safe
|
||||||
|
|
||||||
async def send_split_message(message: Message, header: str, body: str):
|
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:
|
for p in paragraphs:
|
||||||
await message.answer(full_text, parse_mode='HTML')
|
p_len = len(p) + 2
|
||||||
return
|
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)]
|
if current_chunk:
|
||||||
await message.answer(f"{header} (Часть 1):\n\n{parts[0]}", parse_mode='HTML')
|
chunks.append("\n\n".join(current_chunk))
|
||||||
for idx, part in enumerate(parts[1:], 2):
|
|
||||||
await message.answer(f"<i>(Продолжение, часть {idx}):</i>\n\n{part}", parse_mode='HTML')
|
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:
|
async def determine_user_role(user_id: int) -> str:
|
||||||
"""Определяет роль пользователя в системе."""
|
"""Определяет роль пользователя в системе."""
|
||||||
|
|
|
||||||
|
|
@ -26,3 +26,4 @@ google-auth-oauthlib
|
||||||
|
|
||||||
|
|
||||||
edge-tts>=7.0.0
|
edge-tts>=7.0.0
|
||||||
|
notebooklm-py>=0.8.5
|
||||||
|
|
|
||||||
|
|
@ -113,16 +113,39 @@ class AILegalEngine:
|
||||||
|
|
||||||
async def query_notebooklm(self, prompt: str) -> dict:
|
async def query_notebooklm(self, prompt: str) -> dict:
|
||||||
"""Прямой запрос к блокноту NotebookLM «Разбор ответов строго по ЖК РФ»."""
|
"""Прямой запрос к блокноту NotebookLM «Разбор ответов строго по ЖК РФ»."""
|
||||||
|
import shutil
|
||||||
notebook_id = os.getenv("NOTEBOOKLM_ID", "00ed7d92-1ab2-4159-ac72-1d0c4be21377")
|
notebook_id = os.getenv("NOTEBOOKLM_ID", "00ed7d92-1ab2-4159-ac72-1d0c4be21377")
|
||||||
nlm_bins = ["/home/matrixhasyou/.local/bin/nlm", "/usr/local/bin/nlm", "nlm"]
|
candidates = [
|
||||||
nlm_bin = next((b for b in nlm_bins if os.path.exists(b)), "nlm")
|
"/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 = [
|
cmd = [
|
||||||
nlm_bin, "query", "notebook",
|
nb_bin, "ask",
|
||||||
notebook_id,
|
"-n", notebook_id,
|
||||||
prompt
|
prompt
|
||||||
]
|
]
|
||||||
env = os.environ.copy()
|
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"
|
env["SSL_CERT_FILE"] = "/etc/ssl/certs/ca-certificates.crt"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -132,26 +155,35 @@ class AILegalEngine:
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
env=env
|
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()
|
out_str = stdout.decode('utf-8', errors='ignore').strip()
|
||||||
err_str = stderr.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:
|
if proc.returncode == 0 and out_str and "Authentication Error" not in out_str and "Not logged in" not in out_str:
|
||||||
# Очистка предупреждений об апдейте nlm
|
lines = [l for l in out_str.splitlines() if not l.startswith("Continuing conversation") and not l.startswith("Resumed conversation")]
|
||||||
if "Update available:" in out_str:
|
cleaned = "\n".join(lines).strip()
|
||||||
out_str = out_str.split("Update available:")[0].strip()
|
if cleaned.startswith("Answer:"):
|
||||||
return {"success": True, "text": out_str, "source": "notebooklm"}
|
cleaned = cleaned[len("Answer:"):].strip()
|
||||||
|
return {"success": True, "text": cleaned, "source": "notebooklm"}
|
||||||
else:
|
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"}
|
return {"success": False, "error": err_str or out_str, "source": "notebooklm"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
log.error(f"NotebookLM exec exception: {e}")
|
||||||
return {"success": False, "error": str(e), "source": "notebooklm"}
|
return {"success": False, "error": str(e), "source": "notebooklm"}
|
||||||
|
|
||||||
async def query(self, prompt: str, role: str = "resident", audio_path: str = None) -> dict:
|
async def query(self, prompt: str, role: str = "resident", audio_path: str = None) -> dict:
|
||||||
"""Основной метод запроса: NotebookLM (первый приоритет) -> Gemini (fallback)."""
|
"""Основной метод запроса: NotebookLM (первый безусловный приоритет) -> Gemini (fallback/аудио)."""
|
||||||
start_time = datetime.now()
|
start_time = datetime.now()
|
||||||
|
|
||||||
# Если вопрос от Админа и нет аудио — пробуем NotebookLM в первую очередь!
|
# 1. Текстовые вопросы ВСЕХ пользователей идут строго в блокнот «Разбор ответов строго по ЖК РФ»!
|
||||||
if role in ("admin", "lawyer") and not audio_path and prompt:
|
if not audio_path and prompt:
|
||||||
nlm_res = await self.query_notebooklm(prompt)
|
nlm_res = await self.query_notebooklm(prompt)
|
||||||
if nlm_res.get("success"):
|
if nlm_res.get("success"):
|
||||||
latency = (datetime.now() - start_time).total_seconds()
|
latency = (datetime.now() - start_time).total_seconds()
|
||||||
|
|
@ -163,12 +195,14 @@ class AILegalEngine:
|
||||||
"latency_sec": round(latency, 2),
|
"latency_sec": round(latency, 2),
|
||||||
"model": "NotebookLM: Разбор ответов строго по ЖК РФ"
|
"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:
|
if not self.api_key:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"text": "ИИ-модуль временно недоступен. Проверьте API ключ.",
|
"text": "ИИ-модуль временно недоступен. Проверьте подключение.",
|
||||||
"role": role,
|
"role": role,
|
||||||
"latency_sec": 0.0
|
"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")
|
PODCASTS_DIR = Path("/home/matrixhasyou/swarm-services/domovoy-bot/podcasts")
|
||||||
LEGAL_SOURCES_DIR = Path("/home/matrixhasyou/mutt/legal_sources")
|
LEGAL_SOURCES_DIR = Path("/home/matrixhasyou/mutt/legal_sources")
|
||||||
MEGA_VOLUMES_DIR = Path("/home/matrixhasyou/mutt/mega_volumes")
|
MEGA_VOLUMES_DIR = Path("/home/matrixhasyou/mutt/mega_volumes")
|
||||||
DEFAULT_NOTEBOOK_NAME = "МКД ЛенКом 37 (Суды, Прокуратура, ЖКХ)"
|
DEFAULT_NOTEBOOK_NAME = "Разбор ответов строго по ЖК РФ"
|
||||||
|
|
||||||
class NotebookLMPodcastGenerator:
|
class NotebookLMPodcastGenerator:
|
||||||
def __init__(self, notebook_name: str = DEFAULT_NOTEBOOK_NAME):
|
def __init__(self, notebook_name: str = DEFAULT_NOTEBOOK_NAME):
|
||||||
|
|
@ -183,7 +183,7 @@ class NotebookLMPodcastGenerator:
|
||||||
В конце — четкие выводы и 3 практических шага для жителей дома.
|
В конце — четкие выводы и 3 практических шага для жителей дома.
|
||||||
"""
|
"""
|
||||||
response = client.models.generate_content(
|
response = client.models.generate_content(
|
||||||
model="gemini-2.5-pro",
|
model="gemini-2.5-flash",
|
||||||
contents=prompt,
|
contents=prompt,
|
||||||
config=types.GenerateContentConfig(
|
config=types.GenerateContentConfig(
|
||||||
temperature=0.4
|
temperature=0.4
|
||||||
|
|
@ -192,7 +192,7 @@ class NotebookLMPodcastGenerator:
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"script": response.text,
|
"script": response.text,
|
||||||
"engine": "gemini-2.5-pro-fallback"
|
"engine": "gemini-2.5-flash-fallback"
|
||||||
}
|
}
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue