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(): 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"Чем я могу быть вам полезна?" ) await max_api.send_message(user_id, greeting_text) 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) async def handle_message(user_id: int, text: str) -> None: 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 msg = BotMessage( conversation_id=conv.id, direction="incoming", text=text, created_at=moscow_now(), ) db.add(msg) await db.commit() state = conv.state logger.info(f"handle_message: user={user_id} state={state} conv_id={conv.id}") 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"): 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"): async with async_session() as db: conv_new = BotConversation( user_id=user_id, state="analyzing", created_at=moscow_now(), ) db.add(conv_new) await db.commit() await db.refresh(conv_new) await analyze_and_respond(user_id, conv_new.id, text) async def _get_user_history_context(user_id: int) -> str: from app.models import BotTicket async with async_session() as db: recent_msgs = await db.execute( select(BotMessage) .join(BotConversation) .where(BotConversation.user_id == user_id) .order_by(BotMessage.id.desc()) .limit(10) ) 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(): 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: try: logger.info(f"analyze_and_respond: user={user_id} conv={conv_id} text={text[:80]}") ai = await _select_ai() logger.info(f"analyze_and_respond: ai={ai}") if not ai: logger.info("analyze_and_respond: no AI available, limited mode") 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 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) else: await _save_unknown_question(user_id, text) await max_api.send_message(user_id, "По данному вопросу рекомендую связаться с нашим специалистом " "для получения исчерпывающего ответа. " "Контакты: +7 (861) 203-33-30, mail@aegisone.ru" ) conv.state = "completed" elif intent == "ticket": conv.state = "awaiting_consent" consent_text = ( "Для обработки Вашего запроса нам нужны ваши контактные данные " "для обратной связи. Даёте согласие на обработку " "персональных данных?" ) await max_api.send_message( user_id, consent_text, attachments=consent_keyboard() ) else: clarification = await ai.clarify_intent(text, history_context) await max_api.send_message(user_id, clarification) conv.state = "awaiting_input" await db.commit() except Exception as e: logger.exception(f"analyze_and_respond FAILED: {e}") await _send_contacts(user_id) async def _limited_mode(user_id: int, conv_id: int) -> None: 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) async def _send_contacts(user_id: int) -> None: phones = config_reader.get_phones() email = config_reader.get_site_mail() phone_text = "\n".join( f"📞 {phone} — [позвонить](tel:{link})" for phone, link in phones ) message = ( "Вы можете связаться с нами:\n\n" f"{phone_text}\n" f"📧 [Написать на почту](mailto:{email})\n\n" f"Или написать на почту: {email}" ) await max_api.send_message(user_id, message, format="markdown") async def _get_knowledge_base_context(query: str) -> str: 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: 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): 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: 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()