Files
site_aegisone/max_bot/app/handlers/greeting.py
T

536 lines
22 KiB
Python

"""
Обработчик приветствий и основных сообщений бота.
Управляет жизненным циклом диалога:
- приветствие → создание пользователя и диалога
- маршрутизация по состояниям (state machine)
- анализ намерений через YandexGPT
- быстрые паттерны (заявка/приветствие) до NLP
- контекст из последних 10 минут
"""
import logging
import datetime
from typing import Optional
from sqlalchemy import select
from app.database import async_session
MOSCOW_TZ = datetime.timezone(datetime.timedelta(hours=3))
def moscow_now():
"""Возвращает текущее время в UTC+3 (Москва) без tzinfo."""
return datetime.datetime.now(MOSCOW_TZ).replace(tzinfo=None)
from app.models import BotUser, BotConversation, BotMessage, BotKnowledgeBase, BotCategory, BotUnknownQuestion
from app.max_api import max_api
from app.yandex_gpt import yandex_gpt
from app.config_reader import config_reader
from app.settings_cache import settings_cache
from app.keyboards import consent_keyboard, return_to_consent_keyboard
logger = logging.getLogger(__name__)
async def handle_greeting(
user_id: int,
text: Optional[str] = None,
first_name: str = "",
last_name: str = "",
username: str = "",
) -> None:
"""Обработка приветствия: создаёт/обновляет пользователя, создаёт диалог, отправляет приветствие."""
assistant_name = await settings_cache.get("assistant_name", "София")
greeting_text = (
f"Здравствуйте! Я {assistant_name}, помощник AegisOne Engineering. "
f"Чем я могу быть вам полезна?"
)
async with async_session() as db:
existing = await db.execute(
select(BotUser).where(BotUser.id == user_id)
)
user = existing.scalar_one_or_none()
now = moscow_now()
if not user:
try:
user = BotUser(
id=user_id,
first_name=first_name,
last_name=last_name,
username=username,
last_interaction=now,
total_conversations=1,
created_at=now,
)
db.add(user)
await db.commit()
await db.refresh(user)
except Exception:
await db.rollback()
existing = await db.execute(
select(BotUser).where(BotUser.id == user_id)
)
user = existing.scalar_one_or_none()
if user:
if first_name:
user.first_name = first_name
if last_name:
user.last_name = last_name
if username:
user.username = username
user.last_interaction = now
user.total_conversations = (user.total_conversations or 0) + 1
await db.commit()
else:
if first_name:
user.first_name = first_name
if last_name:
user.last_name = last_name
if username:
user.username = username
user.last_interaction = now
user.total_conversations = (user.total_conversations or 0) + 1
await db.commit()
conv = BotConversation(
user_id=user_id,
state="awaiting_input",
created_at=now,
)
db.add(conv)
await db.commit()
await db.refresh(conv)
await max_api.send_message(user_id, greeting_text, conversation_id=conv.id)
async def handle_message(user_id: int, text: str) -> None:
"""
Основной обработчик сообщений. Маршрутизирует по состояниям:
- greeting/analyzing/awaiting_input → analyze_and_respond
- awaiting_consent/consent_refused → обработка согласия
- awaiting_contact → ручной ввод контакта
- awaiting_inquiry → обработка заявки
- completed/escalated → повторный анализ (escape hatch)
"""
async with async_session() as db:
result = await db.execute(
select(BotConversation)
.where(BotConversation.user_id == user_id)
.order_by(BotConversation.id.desc())
.limit(1)
)
conv = result.scalar_one_or_none()
if not conv:
await handle_greeting(user_id, text)
return
now = moscow_now()
stale_threshold = now - datetime.timedelta(minutes=10)
if conv.created_at and conv.created_at < stale_threshold:
conv = BotConversation(
user_id=user_id,
state="awaiting_input",
created_at=now,
)
db.add(conv)
await db.commit()
await db.refresh(conv)
msg = BotMessage(
conversation_id=conv.id,
direction="incoming",
text=text,
created_at=now,
)
db.add(msg)
await db.commit()
state = conv.state
logger.info(f"handle_message: user={user_id} state={state} conv_id={conv.id}")
text_lower = text.strip().lower()
refuse_words = ["нет", "не даю", "не хочу", "отказ", "отмена", "no", "cancel", "я отказался"]
operator_words = ["оператор", "живой человек", "связать с оператором", "переключить на оператора"]
if any(w in text_lower for w in operator_words):
from app.config_reader import config_reader
phones = config_reader.get_phones()
email = config_reader.get_site_mail()
phone_text = "\n".join(f"📞 {phone}" for phone, _ in phones)
await max_api.send_message(
user_id,
"К сожалению, я не могу переключить вас на оператора.\n\n"
"Но вы можете связаться с нами напрямую:\n"
f"{phone_text}\n"
f"📧 {email}\n\n"
"Мы обязательно ответим и поможем!",
conversation_id=conv.id,
)
return
revoke_words = ["отозвать согласие", "отозвать", "отменить согласие"]
if any(w in text_lower for w in revoke_words):
from app.handlers.consent import handle_revoke_consent
await handle_revoke_consent(user_id, conv.id)
return
if state in ("greeting", "analyzing", "awaiting_input"):
await analyze_and_respond(user_id, conv.id, text)
elif state in ("awaiting_consent", "consent_refused", "awaiting_contact"):
if any(w in text_lower for w in refuse_words):
if state == "awaiting_consent":
from app.handlers.consent import handle_consent_no
await handle_consent_no(user_id, conv.id)
elif state == "consent_refused":
from app.handlers.consent import handle_refused_again
await handle_refused_again(user_id, conv.id, text)
elif state == "awaiting_contact":
async with async_session() as db:
conv_upd = await db.get(BotConversation, conv.id)
if conv_upd:
conv_upd.state = "consent_refused"
await db.commit()
from app.handlers.consent import handle_refused_again
await handle_refused_again(user_id, conv.id, text)
return
quick_intent = await yandex_gpt.analyze_intent(text)
if quick_intent == "question":
await analyze_and_respond(user_id, conv.id, text)
return
if state == "awaiting_consent":
from app.handlers.consent import handle_consent_response
await handle_consent_response(user_id, conv.id, text)
elif state == "consent_refused":
from app.handlers.consent import handle_refused_again
await handle_refused_again(user_id, conv.id, text)
elif state == "awaiting_contact":
from app.handlers.contact import handle_manual_contact
await handle_manual_contact(user_id, conv.id, text)
elif state == "awaiting_inquiry":
from app.handlers.inquiry import handle_inquiry
await handle_inquiry(user_id, conv.id, text)
elif state in ("completed", "escalated"):
await analyze_and_respond(user_id, conv.id, text)
async def _get_user_history_context(user_id: int) -> str:
"""Формирует контекст из последних сообщений (10 мин) и открытых заявок пользователя."""
from app.models import BotTicket
async with async_session() as db:
ten_min_ago = moscow_now() - datetime.timedelta(minutes=10)
recent_msgs = await db.execute(
select(BotMessage)
.join(BotConversation)
.where(BotConversation.user_id == user_id)
.where(BotMessage.created_at >= ten_min_ago)
.order_by(BotMessage.id.desc())
)
messages = list(reversed(recent_msgs.scalars().all()))
open_tickets = await db.execute(
select(BotTicket)
.where(BotTicket.user_id == user_id)
.where(BotTicket.status != "Закрыта")
.order_by(BotTicket.id.desc())
)
tickets = open_tickets.scalars().all()
parts = []
if messages:
msg_lines = []
for m in messages:
who = "Клиент" if m.direction == "incoming" else "Бот"
msg_lines.append(f"{who}: {m.text or ''}")
parts.append("Последние сообщения из прошлых диалогов:\n" + "\n".join(msg_lines))
if tickets:
ticket_lines = []
for t in tickets:
ticket_lines.append(f" - Заявка #{t.id}: {t.title} (статус: {t.status})")
parts.append("Открытые заявки пользователя:\n" + "\n".join(ticket_lines))
return "\n\n".join(parts)
async def _select_ai():
"""Выбор AI-провайдера (только YandexGPT)."""
if await yandex_gpt.is_available():
return yandex_gpt
return None
async def analyze_and_respond(user_id: int, conv_id: int, text: str) -> None:
"""
Анализ намерений и генерация ответа.
Быстрые паттерны (заявка/приветствие) проверяются ПЕРЕД NLP.
Затем: analyze_intent → ticket/question/unknown → обработка.
clarify_intent вызывается не более 1 раза за диалог.
"""
try:
logger.info(f"analyze_and_respond: user={user_id} conv={conv_id} text={text[:80]}")
text_lower = text.strip().lower()
ticket_quick = [
"записаться", "заявк", "заказать", "оставить заявку", "нужна помощь",
"хочу заказать", "хочу записаться", "хочу оставить", "оформить заявку",
"связаться", "обратный звонок", "перезвоните", "перезвонить",
]
if any(p in text_lower for p in ticket_quick):
async with async_session() as db:
conv = await db.get(BotConversation, conv_id)
if not conv:
return
conv.intent = "ticket"
conv.state = "awaiting_consent"
await db.commit()
consent_text = (
"Для обработки Вашего запроса нам нужны ваши контактные данные "
"для обратной связи. Даёте согласие на обработку "
"персональных данных?"
)
await max_api.send_message(
user_id, consent_text, attachments=consent_keyboard(), conversation_id=conv_id
)
return
greeting_words = [
"привет", "здравствуйте", "добрый день", "доброе утро", "добрый вечер",
"хай", "хей", "йо", "здарова", "салют",
]
words_only = set(text_lower.split())
if words_only & set(greeting_words):
async with async_session() as db:
conv = await db.get(BotConversation, conv_id)
if conv:
conv.intent = "greeting"
await db.commit()
assistant_name = await settings_cache.get("assistant_name", "София")
await max_api.send_message(
user_id,
f"Здравствуйте! Я {assistant_name}, чем могу помочь?",
conversation_id=conv_id,
)
return
ai = await _select_ai()
if not ai:
await _limited_mode(user_id, conv_id)
return
history_context = await _get_user_history_context(user_id)
intent = await ai.analyze_intent(text, history_context)
logger.info(f"analyze_and_respond: intent={intent}")
async with async_session() as db:
result = await db.execute(
select(BotConversation).where(BotConversation.id == conv_id)
)
conv = result.scalar_one_or_none()
if not conv:
return
prev_intent = conv.intent
conv.intent = intent
if intent == "question":
kb_context = await _get_knowledge_base_context(text)
answer = await ai.generate_answer(text, kb_context, history_context)
if answer:
await max_api.send_message(user_id, answer, conversation_id=conv_id)
else:
await _save_unknown_question(user_id, text)
await max_api.send_message(user_id,
"По данному вопросу рекомендую связаться с нашим специалистом "
"для получения исчерпывающего ответа. "
"Контакты: +7 (861) 203-33-30, mail@aegisone.ru",
conversation_id=conv_id,
)
conv.state = "completed"
elif intent == "ticket":
conv.state = "awaiting_consent"
consent_text = (
"Для обработки Вашего запроса нам нужны ваши контактные данные "
"для обратной связи. Даёте согласие на обработку "
"персональных данных?"
)
await max_api.send_message(
user_id, consent_text, attachments=consent_keyboard(), conversation_id=conv_id
)
else:
if prev_intent == "unknown":
kb_context = await _get_knowledge_base_context(text)
answer = await ai.generate_answer(text, kb_context, history_context)
if answer:
await max_api.send_message(user_id, answer, conversation_id=conv_id)
conv.state = "completed"
else:
await _send_contacts(user_id, conv_id)
conv.state = "completed"
else:
clarification = await ai.clarify_intent(text, history_context)
await max_api.send_message(user_id, clarification, conversation_id=conv_id)
conv.state = "awaiting_input"
await db.commit()
except Exception as e:
logger.exception(f"analyze_and_respond FAILED: {e}")
await _send_contacts(user_id, conv_id)
async def _limited_mode(user_id: int, conv_id: int) -> None:
"""Режим без AI: показывает контакты компании."""
async with async_session() as db:
result = await db.execute(
select(BotConversation).where(BotConversation.id == conv_id)
)
conv = result.scalar_one_or_none()
if conv:
conv.state = "completed"
await db.commit()
await _send_contacts(user_id, conv_id)
async def _send_contacts(user_id: int, conv_id: int = None) -> None:
"""Отправка контактных данных компании (телефон, email)."""
phones = config_reader.get_phones()
email = config_reader.get_site_mail()
phone_text = "\n".join(f"📞 {phone}" for phone, _ in phones)
message = (
"Вы можете связаться с нами:\n\n"
f"{phone_text}\n"
f"📧 {email}"
)
await max_api.send_message(user_id, message, conversation_id=conv_id)
async def _get_knowledge_base_context(query: str) -> str:
"""Формирует контекст из базы знаний по ключевым словам (ранжирование по score)."""
async with async_session() as db:
result = await db.execute(
select(BotKnowledgeBase)
.where(BotKnowledgeBase.is_active == True)
.order_by(BotKnowledgeBase.sort_order)
)
cards = result.scalars().all()
if not cards:
return ""
query_lower = query.lower()
query_words = set(query_lower.split())
matched = []
unmatched = []
for card in cards:
keywords = card.keywords or []
question_lower = (card.question or "").lower()
keywords_lower = " ".join(keywords).lower()
score = 0
for w in query_words:
if len(w) < 3:
continue
if w in keywords_lower:
score += 2
if w in question_lower:
score += 1
entry = (
f"[{card.category.name if card.category else 'Общее'}] "
f"{card.question}: {card.answer or 'Нет ответа'}"
)
if score > 0:
matched.append((score, entry))
else:
unmatched.append(entry)
matched.sort(key=lambda x: -x[0])
parts = [e for _, e in matched] + unmatched
return "\n\n".join(parts[:25])
def _texts_match(text_a: str, text_b: str) -> bool:
"""Проверка схожести двух текстов по пересечению слов (≥50%)."""
stopwords = {"какой", "какая", "какие", "какое", "ваш", "ваша", "ваши",
"где", "когда", "сколько", "что", "это", "или", "для", "при",
"подскажите", "расскажите", "объясните", "пожалуйста"}
words_a = set(text_a.lower().split()) - stopwords
words_b = set(text_b.lower().split()) - stopwords
if not words_a or not words_b:
return False
overlap = len(words_a & words_b) / min(len(words_a), len(words_b))
return overlap >= 0.5
async def _auto_generate_and_add_kb(db, question_record):
"""Автогенерация карточки KB через YandexGPT при 3+ обращениях."""
card_data = await yandex_gpt.generate_kb_card([question_record.question_text])
if not card_data:
return
card = BotKnowledgeBase(
category_id=card_data.get("category_id", 1),
question=card_data.get("question", question_record.question_text),
answer=card_data.get("answer"),
keywords=card_data.get("keywords", []),
is_active=False,
sort_order=999,
)
db.add(card)
question_record.status = "auto_added"
question_record.generated_answer = card_data.get("answer")
question_record.generated_keywords = card_data.get("keywords")
question_record.generated_category_id = card_data.get("category_id")
async def _save_unknown_question(user_id: int, question_text: str) -> None:
"""Сохранение неизвестного вопроса с дедупликацией и автогенерацией при 3+ обращениях."""
normalized_input = question_text.lower().strip()
async with async_session() as db:
result = await db.execute(
select(BotUnknownQuestion)
.where(BotUnknownQuestion.status.in_(["new", "auto_added"]))
.order_by(BotUnknownQuestion.id.desc())
)
all_questions = result.scalars().all()
matched = None
for q in all_questions:
compare_text = q.normalized_text or q.question_text
if _texts_match(normalized_input, compare_text):
matched = q
break
if matched:
matched.ask_count += 1
if user_id not in (matched.user_ids or []):
matched.user_ids = (matched.user_ids or []) + [user_id]
if matched.ask_count >= 3 and matched.status == "new":
await _auto_generate_and_add_kb(db, matched)
else:
db.add(BotUnknownQuestion(
question_text=question_text,
normalized_text=normalized_input,
ask_count=1,
user_ids=[user_id],
status="new",
))
await db.commit()