""" Обработчик приветствий и основных сообщений бота. Управляет жизненным циклом диалога: - приветствие → создание пользователя и диалога - маршрутизация по состояниям (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()