v1.8.0: YandexGPT-only NLP, unknown questions system, callback dedup, consent flow fixes, DELETE cascade, UTC+3, auto-detect sidebar menu, tel:/mailto: links
This commit is contained in:
@@ -52,13 +52,16 @@ async def handle_consent_yes(user_id: int, conv_id: int) -> None:
|
||||
|
||||
await db.commit()
|
||||
|
||||
await max_api.send_message(
|
||||
user_id,
|
||||
"Спасибо! Поделитесь, пожалуйста, вашим контактом, чтобы мы могли "
|
||||
"связаться с вами.\n\n"
|
||||
"Нажмите кнопку «Поделиться контактом» или введите номер телефона / email вручную.",
|
||||
attachments=contact_keyboard(),
|
||||
)
|
||||
try:
|
||||
await max_api.send_message(
|
||||
user_id,
|
||||
"Спасибо! Поделитесь, пожалуйста, вашим контактом, чтобы мы могли "
|
||||
"связаться с вами.\n\n"
|
||||
"Нажмите кнопку «Поделиться контактом» или введите номер телефона / email вручную.",
|
||||
attachments=contact_keyboard(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send consent_yes message: {e}")
|
||||
|
||||
|
||||
async def handle_consent_no(user_id: int, conv_id: int) -> None:
|
||||
@@ -75,16 +78,20 @@ async def handle_consent_no(user_id: int, conv_id: int) -> None:
|
||||
email = config_reader.get_site_mail()
|
||||
|
||||
phone_text = "\n".join(
|
||||
f"📞 {phone}" for phone, _ in phones
|
||||
f"📞 {phone} — [позвонить](tel:{link})"
|
||||
for phone, link in phones
|
||||
)
|
||||
|
||||
await max_api.send_message(
|
||||
user_id,
|
||||
f"Без вашего согласия мы не можем обработать обращение.\n\n"
|
||||
f"Вы можете позвонить нам:\n{phone_text}\n\n"
|
||||
f"Или написать на почту: {email}\n\n"
|
||||
f"Если передумаете и захотите дать согласие — просто напишите об этом.",
|
||||
)
|
||||
try:
|
||||
await max_api.send_message(
|
||||
user_id,
|
||||
f"Без вашего согласия мы не можем обработать обращение.\n\n"
|
||||
f"Вы можете позвонить нам:\n{phone_text}\n\n"
|
||||
f"📧 [Написать на почту](mailto:{email})\n\n"
|
||||
f"Если передумаете и захотите дать согласие — просто напишите об этом.",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send consent_no message: {e}")
|
||||
|
||||
|
||||
async def handle_refused_again(user_id: int, conv_id: int, text: str) -> None:
|
||||
@@ -94,14 +101,10 @@ async def handle_refused_again(user_id: int, conv_id: int, text: str) -> None:
|
||||
if any(w in text_lower for w in consent_words):
|
||||
await handle_consent_yes(user_id, conv_id)
|
||||
else:
|
||||
phones = config_reader.get_phones()
|
||||
email = config_reader.get_site_mail()
|
||||
phone_text = ", ".join(phone for phone, _ in phones)
|
||||
|
||||
await max_api.send_message(
|
||||
user_id,
|
||||
f"Связаться с нами можно только по телефону {phone_text} "
|
||||
f"или по почте {email}. "
|
||||
f"Если хотите дать согласие на обработку данных — напишите «Даю согласие».",
|
||||
attachments=return_to_consent_keyboard(),
|
||||
)
|
||||
async with async_session() as db:
|
||||
conv = await db.get(BotConversation, conv_id)
|
||||
if conv:
|
||||
conv.state = "completed"
|
||||
await db.commit()
|
||||
from app.handlers.greeting import analyze_and_respond
|
||||
await analyze_and_respond(user_id, conv_id, text)
|
||||
|
||||
@@ -3,9 +3,15 @@ import datetime
|
||||
from typing import Optional
|
||||
from sqlalchemy import select
|
||||
from app.database import async_session
|
||||
from app.models import BotUser, BotConversation, BotMessage, BotKnowledgeBase, BotCategory
|
||||
|
||||
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
|
||||
@@ -35,7 +41,7 @@ async def handle_greeting(
|
||||
)
|
||||
user = existing.scalar_one_or_none()
|
||||
|
||||
now = datetime.datetime.utcnow()
|
||||
now = moscow_now()
|
||||
if not user:
|
||||
try:
|
||||
user = BotUser(
|
||||
@@ -79,20 +85,13 @@ async def handle_greeting(
|
||||
|
||||
conv = BotConversation(
|
||||
user_id=user_id,
|
||||
state="analyzing",
|
||||
state="awaiting_input",
|
||||
created_at=now,
|
||||
)
|
||||
db.add(conv)
|
||||
await db.commit()
|
||||
await db.refresh(conv)
|
||||
|
||||
if text:
|
||||
await analyze_and_respond(user_id, conv.id, text)
|
||||
else:
|
||||
async with async_session() as db:
|
||||
conv.state = "awaiting_input"
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def handle_message(user_id: int, text: str) -> None:
|
||||
async with async_session() as db:
|
||||
@@ -112,33 +111,44 @@ async def handle_message(user_id: int, text: str) -> None:
|
||||
conversation_id=conv.id,
|
||||
direction="incoming",
|
||||
text=text,
|
||||
created_at=datetime.datetime.utcnow(),
|
||||
created_at=moscow_now(),
|
||||
)
|
||||
db.add(msg)
|
||||
await db.commit()
|
||||
|
||||
state = conv.state if conv else "greeting"
|
||||
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 == "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 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"):
|
||||
await max_api.send_message(
|
||||
user_id,
|
||||
"Ваше обращение уже передано специалисту. "
|
||||
"Если у вас новый вопрос, напишите его, и я помогу.",
|
||||
)
|
||||
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:
|
||||
@@ -178,59 +188,71 @@ async def _get_user_history_context(user_id: int) -> str:
|
||||
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:
|
||||
gpt_available = await yandex_gpt.is_available()
|
||||
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 gpt_available:
|
||||
await _limited_mode(user_id, conv_id)
|
||||
return
|
||||
|
||||
history_context = await _get_user_history_context(user_id)
|
||||
intent = await yandex_gpt.analyze_intent(text, history_context)
|
||||
|
||||
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:
|
||||
if not ai:
|
||||
logger.info("analyze_and_respond: no AI available, limited mode")
|
||||
await _limited_mode(user_id, conv_id)
|
||||
return
|
||||
conv.intent = intent
|
||||
|
||||
if intent == "question":
|
||||
kb_context = await _get_knowledge_base_context(text)
|
||||
answer = await yandex_gpt.generate_answer(text, kb_context, history_context)
|
||||
if answer:
|
||||
await max_api.send_message(user_id, answer)
|
||||
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:
|
||||
await _send_contacts(user_id)
|
||||
conv.state = "completed"
|
||||
clarification = await ai.clarify_intent(text, history_context)
|
||||
await max_api.send_message(user_id, clarification)
|
||||
conv.state = "awaiting_input"
|
||||
|
||||
elif intent == "ticket":
|
||||
conv.state = "awaiting_consent"
|
||||
consent_text = (
|
||||
"Для обработки Вашего запроса нам нужны ваши контактные данные "
|
||||
"для обратной связи. Даёте согласие на обработку "
|
||||
"персональных данных?"
|
||||
)
|
||||
await max_api.send_message(
|
||||
user_id, consent_text, attachments=consent_keyboard()
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
else:
|
||||
clarification = await yandex_gpt.clarify_intent(text, history_context)
|
||||
await max_api.send_message(user_id, clarification)
|
||||
conv.state = "awaiting_consent"
|
||||
after_text = (
|
||||
"\n\nДля обработки Вашего запроса нам нужны ваши контактные данные "
|
||||
"для обратной связи. Даёте согласие на обработку "
|
||||
"персональных данных?"
|
||||
)
|
||||
await max_api.send_message(
|
||||
user_id, after_text, attachments=consent_keyboard()
|
||||
)
|
||||
|
||||
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:
|
||||
@@ -277,11 +299,103 @@ async def _get_knowledge_base_context(query: str) -> str:
|
||||
if not cards:
|
||||
return ""
|
||||
|
||||
parts = []
|
||||
for card in cards:
|
||||
keywords = " ".join(card.keywords) if card.keywords else ""
|
||||
answer = card.answer or "Нет ответа"
|
||||
cat_name = card.category.name if card.category else "Общее"
|
||||
parts.append(f"[{cat_name}] {card.question}: {answer} (ключевые слова: {keywords})")
|
||||
query_lower = query.lower()
|
||||
query_words = set(query_lower.split())
|
||||
|
||||
return "\n\n".join(parts[:20])
|
||||
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()
|
||||
|
||||
@@ -9,6 +9,11 @@ from app.email_sender import email_sender
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MOSCOW_TZ = datetime.timezone(datetime.timedelta(hours=3))
|
||||
|
||||
def moscow_now():
|
||||
return datetime.datetime.now(MOSCOW_TZ).replace(tzinfo=None)
|
||||
|
||||
|
||||
async def handle_inquiry(user_id: int, conv_id: int, text: str) -> None:
|
||||
async with async_session() as db:
|
||||
@@ -55,6 +60,9 @@ async def _finalize_ticket_in_tx(
|
||||
priority="normal",
|
||||
status="Новая",
|
||||
created_by="bot",
|
||||
contact_name=f"{user.first_name or ''} {user.last_name or ''}".strip() or None,
|
||||
contact_phone=user.phone,
|
||||
contact_email=user.email,
|
||||
)
|
||||
db.add(ticket)
|
||||
await db.flush()
|
||||
|
||||
Reference in New Issue
Block a user