fix(legal): exclude gemini hallucination fallback, enforce strict NotebookLM RAG with STT for voice
This commit is contained in:
parent
36c5e51566
commit
1abadd9081
2 changed files with 112 additions and 90 deletions
|
|
@ -130,21 +130,21 @@ async def cmd_legal_advisor(message: Message, state: FSMContext, bot: Bot):
|
|||
if len(parts) > 1 and parts[0].casefold() in ("/юрист", "/jurist", "/вопрос"):
|
||||
inline_question = parts[1].strip()
|
||||
if len(inline_question) > 3:
|
||||
status_msg = await message.answer("⏳ <i>Анализирую нормы законодательства РФ...</i>", parse_mode='HTML')
|
||||
status_msg = await message.answer("⏳ <i>Изучаю документы, сметы и решения ОСС дома № 37 в блокноте NotebookLM...</i>", parse_mode='HTML')
|
||||
result = await legal_engine.query(prompt=inline_question, role=role)
|
||||
await status_msg.delete()
|
||||
if result.get("success"):
|
||||
header = "⚖️ <b>Юридический ответ:</b>"
|
||||
header = "⚖️ <b>Юридический ответ по документам дома № 37:</b>"
|
||||
await send_split_message(message, header, result["text"])
|
||||
await state.set_state(LegalQuestionState.waiting_for_question)
|
||||
else:
|
||||
await message.answer(f"❌ <b>Ошибка:</b> {result.get('text')}", parse_mode='HTML')
|
||||
await message.answer(f"{result.get('text')}", parse_mode='HTML')
|
||||
return
|
||||
|
||||
text = (
|
||||
"⚖️ <b>Юридический помощник (ЖК РФ)</b>\n\n"
|
||||
"⚖️ <b>Юридический помощник (Дом № 37)</b>\n\n"
|
||||
"Напишите ваш вопрос (текстом или отправьте голосовое сообщение).\n"
|
||||
"<i>Ответ формируется по нормам законодательства РФ и судебной практике.</i>"
|
||||
"<i>Ответ формируется строго по сметам, договорам, протоколам ОСС и нормам ЖК РФ в блокноте NotebookLM.</i>"
|
||||
)
|
||||
await message.answer(text, parse_mode='HTML')
|
||||
await state.set_state(LegalQuestionState.waiting_for_question)
|
||||
|
|
@ -161,9 +161,9 @@ async def cb_legal_advisor(callback: CallbackQuery, state: FSMContext):
|
|||
return
|
||||
|
||||
text = (
|
||||
"⚖️ <b>Юридический помощник (ЖК РФ)</b>\n\n"
|
||||
"⚖️ <b>Юридический помощник (Дом № 37)</b>\n\n"
|
||||
"Напишите ваш вопрос (текстом или отправьте голосовое сообщение).\n"
|
||||
"<i>Ответ формируется по нормам законодательства РФ и судебной практике.</i>"
|
||||
"<i>Ответ формируется строго по сметам, договорам, протоколам ОСС и нормам ЖК РФ в блокноте NotebookLM.</i>"
|
||||
)
|
||||
await callback.message.answer(text, parse_mode='HTML')
|
||||
await state.set_state(LegalQuestionState.waiting_for_question)
|
||||
|
|
@ -239,7 +239,7 @@ async def process_text_legal_question(message: Message, state: FSMContext, bot:
|
|||
question = message.text
|
||||
|
||||
status_msg = await message.answer(
|
||||
"⏳ <i>Анализирую нормы законодательства РФ...</i>",
|
||||
"⏳ <i>Изучаю документы, сметы и решения ОСС дома № 37 в блокноте NotebookLM...</i>",
|
||||
parse_mode='HTML'
|
||||
)
|
||||
|
||||
|
|
@ -247,7 +247,7 @@ async def process_text_legal_question(message: Message, state: FSMContext, bot:
|
|||
await status_msg.delete()
|
||||
|
||||
if not result.get("success"):
|
||||
await message.answer(f"❌ <b>Ошибка:</b> {result.get('text')}", parse_mode='HTML')
|
||||
await message.answer(f"{result.get('text')}", parse_mode='HTML')
|
||||
return
|
||||
|
||||
# Защита от спама: если вопрос задан в групповом чате
|
||||
|
|
@ -256,7 +256,7 @@ async def process_text_legal_question(message: Message, state: FSMContext, bot:
|
|||
try:
|
||||
await bot.send_message(
|
||||
chat_id=user_id,
|
||||
text=f"⚖️ <b>Юридический ответ:</b>\n\n{format_telegram_html(result['text'])}",
|
||||
text=f"⚖️ <b>Юридический ответ по документам дома № 37:</b>\n\n{format_telegram_html(result['text'])}",
|
||||
parse_mode='HTML',
|
||||
reply_markup=get_legal_nav_keyboard()
|
||||
)
|
||||
|
|
@ -271,7 +271,7 @@ async def process_text_legal_question(message: Message, state: FSMContext, bot:
|
|||
)
|
||||
else:
|
||||
# В личном диалоге отвечаем напрямую
|
||||
header = "⚖️ <b>Юридический ответ:</b>"
|
||||
header = "⚖️ <b>Юридический ответ по документам дома № 37:</b>"
|
||||
await send_split_message(message, header, result["text"])
|
||||
|
||||
# Оставляем состояние активным, чтобы жилец мог сразу задать следующий вопрос без клика
|
||||
|
|
@ -293,7 +293,7 @@ async def process_voice_legal_question(message: Message, state: FSMContext, bot:
|
|||
return
|
||||
|
||||
status_msg = await message.answer(
|
||||
"🎙 <i>Анализирую голосовое обращение по нормам законодательства...</i>",
|
||||
"🎙 <i>Распознаю аудио и сверяю с документами дома № 37 в NotebookLM...</i>",
|
||||
parse_mode='HTML'
|
||||
)
|
||||
|
||||
|
|
@ -304,16 +304,21 @@ async def process_voice_legal_question(message: Message, state: FSMContext, bot:
|
|||
voice_file_path = str(TEMP_VOICE_DIR / f"voice_{user_id}_{int(message.date.timestamp())}.ogg")
|
||||
await bot.download_file(file_info.file_path, destination=voice_file_path)
|
||||
|
||||
# Передаем напрямую в мультимодальный Gemini
|
||||
# Передаем в legal_engine (Gemini транскрибирует аудио в текст, затем NotebookLM ищет по базе дома)
|
||||
result = await legal_engine.query(prompt="", role=role, audio_path=voice_file_path)
|
||||
|
||||
await status_msg.delete()
|
||||
|
||||
if not result.get("success"):
|
||||
await message.answer(f"❌ <b>Ошибка:</b> {result.get('text')}", parse_mode='HTML')
|
||||
await message.answer(f"{result.get('text')}", parse_mode='HTML')
|
||||
return
|
||||
|
||||
header = "⚖️ <b>Юридический ответ:</b>"
|
||||
transcribed = result.get("transcribed_question")
|
||||
if transcribed:
|
||||
header = f"⚖️ <b>Юридический ответ по документам дома № 37:</b>\n<i>(Вопрос: «{format_telegram_html(transcribed)}»)</i>"
|
||||
else:
|
||||
header = "⚖️ <b>Юридический ответ по документам дома № 37:</b>"
|
||||
|
||||
await send_split_message(message, header, result["text"])
|
||||
await state.set_state(LegalQuestionState.waiting_for_question)
|
||||
|
||||
|
|
|
|||
|
|
@ -99,8 +99,36 @@ class AILegalEngine:
|
|||
data = await resp.json()
|
||||
return data["candidates"][0]["content"]["parts"][0]["text"]
|
||||
|
||||
async def transcribe_audio(self, audio_path: str) -> str:
|
||||
"""Транскрибирует аудиосообщение жильца в текст через Gemini Multimodal Audio (Voice-to-Text)."""
|
||||
if not self.api_key or not audio_path or not os.path.exists(audio_path):
|
||||
return ""
|
||||
|
||||
prompt = (
|
||||
"Дословно расшифруй аудиосообщение на русском языке. "
|
||||
"Не добавляй пояснений, не отвечай на вопрос и не искажай слова жильца. "
|
||||
"Выведи ТОЛЬКО расшифрованный текст."
|
||||
)
|
||||
system_instruction = "Ты — высокоточный модуль транскрибации речи (Speech-to-Text). Верни только произнесенный текст."
|
||||
|
||||
for model_to_use in [self.model_name, "gemini-flash-latest", "gemini-2.5-flash"]:
|
||||
try:
|
||||
transcription = await self._query_rest_api(
|
||||
prompt=prompt,
|
||||
system_instruction=system_instruction,
|
||||
model=model_to_use,
|
||||
audio_path=audio_path
|
||||
)
|
||||
if transcription and transcription.strip():
|
||||
return transcription.strip()
|
||||
except Exception as e:
|
||||
log.warning(f"Voice transcription attempt failed on {model_to_use}: {e}")
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
return ""
|
||||
|
||||
async def query_notebooklm(self, prompt: str) -> dict:
|
||||
"""Прямой запрос к блокноту NotebookLM «ЛенКом37 Юрист»."""
|
||||
"""Прямой запрос к блокноту NotebookLM «ЛенКом37 Юрист» (RAG по документам дома № 37)."""
|
||||
import shutil
|
||||
notebook_id = os.getenv("NOTEBOOKLM_ID", "59c41b23-5858-4a74-bf60-4ce17e0ca710")
|
||||
candidates = [
|
||||
|
|
@ -112,7 +140,6 @@ class AILegalEngine:
|
|||
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()
|
||||
|
|
@ -144,109 +171,99 @@ class AILegalEngine:
|
|||
env=env
|
||||
)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=180.0)
|
||||
# Увеличенный лимит ожидания (до 300 сек для глубокого RAG-анализа смет и решений)
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=300.0)
|
||||
except asyncio.TimeoutError:
|
||||
try: proc.kill()
|
||||
except: pass
|
||||
return {"success": False, "error": "Таймаут обращения к NotebookLM (более 180 сек)", "source": "notebooklm"}
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Таймаут обработки запроса к блокноту документов дома (более 300 сек)",
|
||||
"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 "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")]
|
||||
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]}")
|
||||
log.warning(f"NotebookLM non-zero or auth err: code={proc.returncode}, out={out_str[:250]}, err={err_str[:250]}")
|
||||
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/аудио)."""
|
||||
"""
|
||||
Канонический метод запроса юридического помощника МКД «Домовой 2.0»:
|
||||
- Все ответы формируются СТРОГО на основе документов дома № 37 из блокнота NotebookLM.
|
||||
- Никаких общих абстрактных ответов или галлюцинаций Gemini!
|
||||
- При отправке аудиосообщения: Gemini выполняет ИСКЛЮЧИТЕЛЬНО распознавание голоса в текст (STT),
|
||||
после чего текст вопроса направляется в NotebookLM.
|
||||
"""
|
||||
start_time = datetime.now()
|
||||
transcribed_text = None
|
||||
|
||||
# 1. Текстовые вопросы ВСЕХ пользователей идут строго в блокнот «Разбор ответов строго по ЖК РФ»!
|
||||
if not audio_path and prompt:
|
||||
# 1. Если передано аудиосообщение — транскрибируем его через Gemini Multimodal в текст
|
||||
if audio_path:
|
||||
log.info(f"Received audio voice message ({audio_path}), transcribing via Gemini STT...")
|
||||
transcribed_text = await self.transcribe_audio(audio_path)
|
||||
if not transcribed_text:
|
||||
return {
|
||||
"success": False,
|
||||
"text": "❌ <b>Не удалось распознать голосовое сообщение.</b> Пожалуйста, напишите ваш вопрос текстом.",
|
||||
"role": role,
|
||||
"latency_sec": round((datetime.now() - start_time).total_seconds(), 2)
|
||||
}
|
||||
log.info(f"Audio voice successfully transcribed: '{transcribed_text[:120]}...'")
|
||||
prompt = transcribed_text
|
||||
|
||||
if not prompt or not prompt.strip():
|
||||
return {
|
||||
"success": False,
|
||||
"text": "❌ Вопрос пустой. Пожалуйста, сформулируйте юридический вопрос.",
|
||||
"role": role,
|
||||
"latency_sec": 0.0
|
||||
}
|
||||
|
||||
# 2. Обращение СТРОГО к блокноту NotebookLM (RAG по сметам, договорам и протоколам ОСС)
|
||||
nlm_res = await self.query_notebooklm(prompt)
|
||||
latency = round((datetime.now() - start_time).total_seconds(), 2)
|
||||
|
||||
if nlm_res.get("success"):
|
||||
latency = (datetime.now() - start_time).total_seconds()
|
||||
return {
|
||||
"success": True,
|
||||
"text": nlm_res["text"],
|
||||
"answer": nlm_res["text"],
|
||||
"role": role,
|
||||
"latency_sec": round(latency, 2),
|
||||
"model": "NotebookLM: Разбор ответов строго по ЖК РФ"
|
||||
"latency_sec": latency,
|
||||
"model": "NotebookLM: ЛенКом37 Юрист (Документы дома № 37)",
|
||||
"transcribed_question": transcribed_text
|
||||
}
|
||||
else:
|
||||
log.warning(f"NotebookLM query failed: {nlm_res.get('error')}. Falling back to Gemini.")
|
||||
|
||||
# 2. Резервный контур через Gemini API (для аудиосообщений либо если блокнот временно недоступен)
|
||||
if not self.api_key:
|
||||
err_msg = nlm_res.get("error", "Неизвестная ошибка")
|
||||
log.error(f"NotebookLM legal query failed: {err_msg}")
|
||||
# Никакого fallback на обычный Gemini! Выдаем честное уведомление об обновлении базы
|
||||
return {
|
||||
"success": False,
|
||||
"text": "ИИ-модуль временно недоступен. Проверьте подключение.",
|
||||
"text": (
|
||||
"❌ <b>База документов дома № 37 временно обновляет сессию.</b>\n\n"
|
||||
"Юридический помощник не выдает выдуманных общих ответов нейросетей, "
|
||||
"а обращается строго к протоколам ОСС, сметам и договорам нашего дома в NotebookLM. "
|
||||
"Пожалуйста, повторите вопрос через минуту."
|
||||
),
|
||||
"role": role,
|
||||
"latency_sec": 0.0
|
||||
}
|
||||
|
||||
system_instruction = (
|
||||
"Ты — профессиональный юридический консультант по жилищному праву РФ (ЖК РФ, ГК РФ, защита прав потребителей в сфере ЖКХ).\n"
|
||||
"ЖЕСТКИЕ ПРАВИЛА ОФОРМЛЕНИЯ:\n"
|
||||
"1. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО писать любые самопрезентации, самоназвания, титулы, приветствия или регалии (никаких 'Я — аналитик', 'Главный стратег', 'Юридический щит', 'Здравствуйте', 'Я помогу вам').\n"
|
||||
"2. Ответ начинать СТРОГО сразу по существу вопроса.\n"
|
||||
"3. Полный минимализм: только факты, правовые нормы, статьи законов и пошаговый алгоритм действий. Без рекламной шелухи и упоминания технологий/моделей.\n"
|
||||
f"{self.get_role_instructions(role)}"
|
||||
)
|
||||
|
||||
# Каскад моделей при перегрузке или временной недоступности (503/429)
|
||||
models_to_try = [
|
||||
self.model_name,
|
||||
"gemini-flash-latest",
|
||||
"gemini-3-flash-preview"
|
||||
]
|
||||
# Убираем дубликаты с сохранением порядка
|
||||
seen = set()
|
||||
models_cascade = [m for m in models_to_try if not (m in seen or seen.add(m))]
|
||||
|
||||
last_err = None
|
||||
for model_to_use in models_cascade:
|
||||
try:
|
||||
reply_text = await self._query_rest_api(
|
||||
prompt=prompt,
|
||||
system_instruction=system_instruction,
|
||||
model=model_to_use,
|
||||
audio_path=audio_path
|
||||
)
|
||||
|
||||
latency = (datetime.now() - start_time).total_seconds()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"text": reply_text.strip(),
|
||||
"answer": reply_text.strip(),
|
||||
"role": role,
|
||||
"latency_sec": round(latency, 2),
|
||||
"model": model_to_use
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
log.warning(f"Model {model_to_use} failed ({e}), trying next fallback...")
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
latency = (datetime.now() - start_time).total_seconds()
|
||||
log.error(f"All Gemini fallback models failed: {last_err}")
|
||||
return {
|
||||
"success": False,
|
||||
"text": f"Шлюз ИИ временно перегружен. Пожалуйста, повторите вопрос через несколько секунд.",
|
||||
"role": role,
|
||||
"latency_sec": round(latency, 2)
|
||||
"latency_sec": latency,
|
||||
"error_detail": err_msg
|
||||
}
|
||||
|
||||
# Глобальный синглтон юридического ядра для роутеров бота
|
||||
|
|
@ -255,7 +272,7 @@ legal_engine = AILegalEngine()
|
|||
if __name__ == "__main__":
|
||||
async def test():
|
||||
engine = AILegalEngine()
|
||||
res = await engine.query("Какие сроки ответа прокуратуры по закону 59-ФЗ?", role="resident")
|
||||
res = await engine.query("Сравни за что мы голосуем на общих собраниях - за обрезку или глубокую обрезку деревьев во дворе. А в отчетах - как списывают?")
|
||||
print("Success:", res["success"])
|
||||
print("Latency:", res["latency_sec"])
|
||||
print("Model:", res.get("model"))
|
||||
|
|
|
|||
Loading…
Reference in a new issue