From 45b235f1596bc11241b997cf12946a9fca97d732 Mon Sep 17 00:00:00 2001 From: pilot <657434@03b.ru> Date: Sun, 26 Jul 2026 21:54:23 +0800 Subject: [PATCH] =?UTF-8?q?=D0=B2=D0=BD=D0=B5=D0=B4=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D1=8F=D0=BD=D0=B4=D0=B5=D0=BA=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- restoran_max_bot/ai_agent/api_common.py | 150 ++++++++++++------ restoran_max_bot/ai_agent/api_yandex_ai.py | 13 +- .../ai_agent/system_prompt_base.txt | 5 + .../restoran_max_bot/bot_message.py | 89 +++++++++-- restoran_max_bot/restoran_max_bot/common.py | 19 +++ 5 files changed, 207 insertions(+), 69 deletions(-) diff --git a/restoran_max_bot/ai_agent/api_common.py b/restoran_max_bot/ai_agent/api_common.py index d61534f..fe3980a 100644 --- a/restoran_max_bot/ai_agent/api_common.py +++ b/restoran_max_bot/ai_agent/api_common.py @@ -3,7 +3,7 @@ import os import json import re from datetime import datetime -from typing import Dict, Any, List, Optional +from typing import Dict, Any, Optional from django.core.cache import cache from max_bot.models import Client, ProductCategory, Product, Qr, Partner, Integration from restoran_max_bot import settings @@ -30,6 +30,7 @@ AI_AGENT_REGISTRY = { def _log_dialogue(session_id: str, role: str, content: str, log_file: str = None) -> None: + """Логирует сообщение, если роль не SYSTEM или включено логирование SYSTEM.""" if role.upper() == 'SYSTEM' and not getattr(settings, 'LOG_SYSTEM_PROMPT', False): return @@ -51,6 +52,7 @@ def _log_dialogue(session_id: str, role: str, content: str, log_file: str = None def _load_base_prompt() -> str: + """Загружает базовый текст системного промта из файла.""" global _BASE_PROMPT_CACHE if _BASE_PROMPT_CACHE is not None: return _BASE_PROMPT_CACHE @@ -71,56 +73,65 @@ def _load_base_prompt() -> str: return _BASE_PROMPT_CACHE -def get_system_prompt(client: Client) -> str: - cache_key = f"system_prompt_{client.id}" - cached = cache.get(cache_key) - if cached: - return cached - - prompt = _load_base_prompt() - - qr = Qr.objects.filter(client=client).first() - if qr: - restaurant_info = f""" - Название: {qr.title or 'Не указано'} - Бренд: {qr.brand or 'Не указан'} - Описание: {qr.description or 'Нет описания'} - Адрес: {qr.addr or 'Не указан'} - Телефон: {qr.phone or 'Не указан'} - Часы работы: {qr.worktime or 'Не указаны'} - Активность: {qr.activity or 'Не указана'} +def get_system_prompt(client: Client, name: str = None) -> str: + """ + Генерирует системный промпт с учётом имени клиента. + Базовая часть кешируется отдельно. + """ + # Кешируем базовую часть (без имени) + cache_key = f"system_prompt_base_{client.pk}" + base_prompt = cache.get(cache_key) + if not base_prompt: + base_prompt = _load_base_prompt() + + qr = Qr.objects.filter(client=client).first() + if qr: + restaurant_info = f""" + Название: {qr.title or 'Не указано'} + Бренд: {qr.brand or 'Не указан'} + Описание: {qr.description or 'Нет описания'} + Адрес: {qr.addr or 'Не указан'} + Телефон: {qr.phone or 'Не указан'} + Часы работы: {qr.worktime or 'Не указаны'} + Активность: {qr.activity or 'Не указана'} + """ + base_prompt += restaurant_info + else: + base_prompt += "\nИнформация о ресторане отсутствует.\n" + + categories = ProductCategory.objects.filter(client=client, status=True, src=2).order_by('ord') + if categories.exists(): + base_prompt += "\nМеню:\n" + for cat in categories: + base_prompt += f"\nКатегория: {cat.title}\n" + products = Product.objects.filter(client=client, up=cat.id, status=1) + if products.exists(): + for prod in products: + base_prompt += f" - {prod.title} — {prod.price} руб." + if prod.descr: + base_prompt += f" ({prod.descr})" + base_prompt += "\n" + else: + base_prompt += " (в этой категории пока нет блюд)\n" + else: + base_prompt += "\nМеню не загружено.\n" + + base_prompt += """ + При ответах на вопросы о меню используй только те блюда, которые перечислены выше. + Если клиент спрашивает о блюде, которого нет в списке, честно скажи об этом и предложи посмотреть другие позиции. + Всегда помни, что ты должен возвращать только JSON, без лишнего текста вне JSON. """ - prompt += restaurant_info - else: - prompt += "\nИнформация о ресторане отсутствует.\n" - - categories = ProductCategory.objects.filter(client=client, status=True, src=2).order_by('ord') - if categories.exists(): - prompt += "\nМеню:\n" - for cat in categories: - prompt += f"\nКатегория: {cat.title}\n" - products = Product.objects.filter(client=client, up=cat.id, status=1) - if products.exists(): - for prod in products: - prompt += f" - {prod.title} — {prod.price} руб." - if prod.descr: - prompt += f" ({prod.descr})" - prompt += "\n" - else: - prompt += " (в этой категории пока нет блюд)\n" - else: - prompt += "\nМеню не загружено.\n" + cache.set(cache_key, base_prompt, 300) # 5 минут - prompt += """ - При ответах на вопросы о меню используй только те блюда, которые перечислены выше. - Если клиент спрашивает о блюде, которого нет в списке, честно скажи об этом и предложи посмотреть другие позиции. - Всегда помни, что ты должен возвращать только JSON, без лишнего текста вне JSON. - """ - cache.set(cache_key, prompt, 300) - return prompt + # Добавляем имя, если оно валидно + if name and is_valid_name(name): + return f"Обращайся к клиенту по имени: {name}. Используй обращение в каждом ответе.\n\n{base_prompt}" + else: + return base_prompt def parse_ai_response(assistant_content: str) -> Dict[str, Any]: + """Универсальный парсер для ответа AI (JSON).""" content = re.sub(r'```(?:json)?\s*', '', assistant_content) content = re.sub(r'\s*```', '', content) content = content.strip() @@ -147,6 +158,7 @@ def parse_ai_response(assistant_content: str) -> Dict[str, Any]: def get_ai_agent(client: Client) -> Dict[str, Optional[str]]: + """Определяет, какой AI-агент подключен к клиенту.""" for agent_slug, config in AI_AGENT_REGISTRY.items(): partner = Partner.objects.filter(slug=agent_slug).first() if not partner: @@ -165,12 +177,12 @@ def get_ai_agent(client: Client) -> Dict[str, Optional[str]]: required_settings = config.get('required_settings', []) missing = [f for f in required_settings if f not in settings] if missing: - _log_dialogue(str(client.id), 'ERROR', + _log_dialogue(str(client.pk), 'ERROR', f"AI agent {agent_slug} missing settings: {missing}") continue if not integration.token: - _log_dialogue(str(client.id), 'ERROR', + _log_dialogue(str(client.pk), 'ERROR', f"AI agent {agent_slug} missing token") continue @@ -193,7 +205,10 @@ def get_ai_agent(client: Client) -> Dict[str, Optional[str]]: } -def call_ai_agent(client: Client, session_id: str, user_query: str) -> Dict[str, Any]: +def call_ai_agent(client: Client, session_id: str, user_query: str, contact_name: str = None) -> Dict[str, Any]: + """ + Универсальный вызов AI-агента с передачей имени клиента. + """ agent_info = get_ai_agent(client) ai_agent = agent_info.get('ai_agent') if not ai_agent: @@ -226,7 +241,7 @@ def call_ai_agent(client: Client, session_id: str, user_query: str) -> Dict[str, "status": "complete" } - system_prompt = get_system_prompt(client) + system_prompt = get_system_prompt(client, name=contact_name) client_prompt = agent_info.get('client_prompt') if client_prompt: system_prompt += f"\n\nДополнительная информация о ресторане:\n{client_prompt}" @@ -248,11 +263,9 @@ def call_ai_agent(client: Client, session_id: str, user_query: str) -> Dict[str, "status": "complete" } kwargs['folder_id'] = folder_id - # Можно задать модель из настроек или использовать default model = agent_info.get('settings', {}).get('model', config.get('default_model', 'yandexgpt')) kwargs['model'] = model - # Логируем вызов _log_dialogue(session_id, 'DEBUG', f"Calling {ai_agent} with kwargs: {kwargs}") try: @@ -266,3 +279,36 @@ def call_ai_agent(client: Client, session_id: str, user_query: str) -> Dict[str, "entities": {}, "status": "complete" } + + +# ---------- работа с мета-данными контакта ---------- +def get_contact_meta(contact) -> Dict[str, Any]: + """Извлекает JSON-данные из поля descr контакта.""" + if not contact.descr: + return {} + try: + return json.loads(contact.descr) + except json.JSONDecodeError: + return {} + + +def set_contact_meta(contact, key: str, value: Any): + """Устанавливает ключ в JSON-данных контакта и сохраняет.""" + meta = get_contact_meta(contact) + meta[key] = value + contact.descr = json.dumps(meta, ensure_ascii=False) + contact.save(update_fields=['descr']) + + +def is_valid_name(name: str) -> bool: + """Проверяет, является ли имя валидным (не никнейм, не телефон).""" + if not name: + return False + name = name.strip() + if len(name) < 2: + return False + if re.match(r'^[0-9@_\-\.]+$', name): + return False + if re.match(r'^\+?[0-9]{10,15}$', name): + return False + return True diff --git a/restoran_max_bot/ai_agent/api_yandex_ai.py b/restoran_max_bot/ai_agent/api_yandex_ai.py index ec055c4..7838e89 100644 --- a/restoran_max_bot/ai_agent/api_yandex_ai.py +++ b/restoran_max_bot/ai_agent/api_yandex_ai.py @@ -12,7 +12,7 @@ def yandex_chat_structured( folder_id: str, system_prompt: str, user_query: str, - model: str = "yandexgpt", # можно "yandexgpt-lite" + model: str = "yandexgpt", # имя модели (yandexgpt или yandexgpt-lite) max_tokens: int = 500, temperature: float = 0.3, ) -> Dict[str, Any]: @@ -32,18 +32,21 @@ def yandex_chat_structured( if not folder_id: raise ValueError("folder_id is empty") + # Формируем URI модели в формате, который ожидает Yandex + model_uri = f"gpt://{folder_id}/{model}/latest" + client = OpenAI( api_key=api_token, base_url="https://ai.api.cloud.yandex.net/v1", - project=folder_id, + project=folder_id, # folder_id для аутентификации ) - # Убираем response_format – он может вызывать ошибку парсинга URI модели response = client.chat.completions.create( - model=model, + model=model_uri, # <-- используем полный URI messages=_contexts[session_id], max_tokens=max_tokens, temperature=temperature, + # response_format не используем ) assistant_content = response.choices[0].message.content @@ -61,4 +64,4 @@ def yandex_chat_structured( "message": error_msg, "entities": {}, "status": "complete" - } + } \ No newline at end of file diff --git a/restoran_max_bot/ai_agent/system_prompt_base.txt b/restoran_max_bot/ai_agent/system_prompt_base.txt index 16401fd..90053a0 100644 --- a/restoran_max_bot/ai_agent/system_prompt_base.txt +++ b/restoran_max_bot/ai_agent/system_prompt_base.txt @@ -34,6 +34,11 @@ - вопрос/проблему (question), чтобы администратор мог подготовиться. - Когда все обязательные поля (phone, contact_method, question) собраны, укажи status: "complete". + 4. **Сбор отзыва (intent: "feedback")**: + - Если клиент хочет оставить отзыв (фразы вроде "хочу оставить отзыв", "оценка", "как оценить"), то запроси оценку (от 1 до 5) и текст отзыва. + - Когда все данные собраны (rating и text), укажи status: "complete" и заполни entities: {"rating": число, "feedback_text": "текст"}. + - Если клиент не указал оценку или текст, задай уточняющий вопрос. + Важно: - Если клиент не указал какую-то информацию, задавай уточняющий вопрос и возвращай status: "need_more_info". - Не выдумывай данные, которых нет. diff --git a/restoran_max_bot/restoran_max_bot/bot_message.py b/restoran_max_bot/restoran_max_bot/bot_message.py index 617e1a0..cae7e62 100644 --- a/restoran_max_bot/restoran_max_bot/bot_message.py +++ b/restoran_max_bot/restoran_max_bot/bot_message.py @@ -1,8 +1,7 @@ # bot_message.py import re -import json import qrcode -from ai_agent.api_common import get_system_prompt, get_ai_agent, call_ai_agent +from ai_agent.api_common import get_ai_agent, call_ai_agent, get_contact_meta, set_contact_meta, is_valid_name from max_bot.max_api import * from restoran_max_bot.bot_started import check_registration from restoran_max_bot.common import * @@ -13,13 +12,25 @@ from restoran_max_bot.utils import has_key, is_json # ---------- вспомогательные функции ---------- -def send_booking_to_crm(chat_id, date, time, people, phone): - # TODO: реализовать отправку в CRM +def send_booking_to_crm(client: Client, date, time, people, phone, name): + # TODO: реализация бронирования столика + msg = f'Бронирование столика:\nНа дату: {date}-{time} \nКоличество: {people}\nКонтакт: {phone}\nИмя: {name}' + send_message_all_manager(client, msg) return True -def send_contact_to_admin(chat_id, phone, contact_method, question, preferred_time, contact): +def send_contact_to_admin(client: Client, phone, contact_method, question, preferred_time, name): # TODO: реализовать отправку администратору + msg = (f'Гость попросил связаться:\n {contact_method}\nВопрос: {question}\n' + f'Удобное время: {preferred_time}\nКонтакт: {phone}\nИмя: {name}') + send_message_all_manager(client, msg) + return True + + +def send_feedback_to_crm(client: Client, rating, feedback_text, name, phone): + # TODO: реализовать отправку отзыва в CRM + msg = f'Отзыв от {name} (тел. {phone}):\nОценка: {rating}/5\nТекст: {feedback_text}' + send_message_all_manager(client, msg) return True @@ -93,6 +104,41 @@ def bot_message(client: Client, message: dict, settings_max: dict, message_type) if not contact: return False + # ---------- проверка и запрос имени ---------- + meta = get_contact_meta(contact) + real_name = meta.get('real_name') + name_asked = meta.get('name_asked', False) + + # Если имя ещё не запрашивали и нет реального имени + if not real_name and not name_asked: + # Проверяем имя, которое пришло из мессенджера (contact.name) + if is_valid_name(contact.name): + # Если валидное, сохраняем как реальное + set_contact_meta(contact, 'real_name', contact.name.strip()) + real_name = contact.name.strip() + else: + # Если невалидное — запрашиваем + maxbot_send_text_message(chat_id, settings_max['token'], + "Как мне к вам обращаться? Напишите ваше имя, чтобы я мог обращаться к вам лично.") + set_contact_meta(contact, 'name_asked', True) + # Завершаем обработку, ждём ответ + return True + + # Если имя уже запрашивали, но пользователь не ответил, а сейчас прислал сообщение + if name_asked and not real_name: + potential_name = message['message']['body']['text'].strip() + if is_valid_name(potential_name): + set_contact_meta(contact, 'real_name', potential_name) + real_name = potential_name + maxbot_send_text_message(chat_id, settings_max['token'], + f"Приятно познакомиться, {potential_name}! Чем могу помочь?") + # После приветствия продолжаем обработку (не завершаем) + else: + maxbot_send_text_message(chat_id, settings_max['token'], + "Пожалуйста, напишите ваше настоящее имя (от 2 символов).") + return True + + # ---------- обработка стандартных команд (меню, бонусы и т.п.) ---------- data = None if message_type == 'message_created': data = common_get_data(client=client, key=str(message['message']['body']['text']).lower()) @@ -100,7 +146,6 @@ def bot_message(client: Client, message: dict, settings_max: dict, message_type) payload = json.loads(message['callback']['payload']) data = common_get_data(client=client, key=str(payload['data']).lower()) - # ---------- обработка стандартных команд (меню, бонусы и т.п.) ---------- if data: for dt in data: if dt.title == '##bonus##': @@ -152,11 +197,12 @@ def bot_message(client: Client, message: dict, settings_max: dict, message_type) message='👇 Выберите раздел', client=client) return True - # Вызываем AI-агента через универсальную функцию + # Вызываем AI-агента через универсальную функцию, передавая реальное имя response = call_ai_agent( client=client, session_id=str(chat_id), - user_query=message['message']['body']['text'] + user_query=message['message']['body']['text'], + contact_name=real_name # передаём имя для персонализации ) # ---------- обработка ответа AI ---------- @@ -177,9 +223,10 @@ def bot_message(client: Client, message: dict, settings_max: dict, message_type) people = entities.get('people') phone = entities.get('phone') if date and time and people: - success = send_booking_to_crm(chat_id, date, time, people, phone) + success = send_booking_to_crm(client, date, time, people, phone, real_name) if success: - confirm_msg = f"✅ Бронирование на {date} в {time} на {people} чел. принято! Администратор свяжется с вами." + confirm_msg = (f"✅ Бронирование на {date} в {time} на {people} чел. принято! " + f"Администратор свяжется с вами.") else: confirm_msg = "❌ Не удалось забронировать. Попробуйте позже." maxbot_send_text_message(chat_id, settings_max['token'], confirm_msg) @@ -197,7 +244,7 @@ def bot_message(client: Client, message: dict, settings_max: dict, message_type) question = entities.get('question', '') preferred_time = entities.get('preferred_time', '') if phone and contact_method: - success = send_contact_to_admin(chat_id, phone, contact_method, question, preferred_time, contact) + success = send_contact_to_admin(client, phone, contact_method, question, preferred_time, real_name) if success: confirm_msg = f"✅ Ваш запрос передан администратору. Способ связи: {contact_method}. Скоро с вами свяжутся." else: @@ -209,10 +256,28 @@ def bot_message(client: Client, message: dict, settings_max: dict, message_type) else: maxbot_send_text_message(chat_id, settings_max['token'], message_text) + elif intent == 'feedback': + if status == 'need_more_info': + maxbot_send_text_message(chat_id, settings_max['token'], message_text) + elif status == 'complete': + rating = entities.get('rating') + feedback_text = entities.get('feedback_text', '') + if rating and feedback_text: + success = send_feedback_to_crm(client, rating, feedback_text, real_name, contact.phone) + if success: + confirm_msg = "✅ Спасибо за ваш отзыв! Мы учтём его." + else: + confirm_msg = "❌ Не удалось отправить отзыв. Попробуйте позже." + maxbot_send_text_message(chat_id, settings_max['token'], confirm_msg) + else: + maxbot_send_text_message(chat_id, settings_max['token'], "⚠️ Не хватает данных для отзыва.") + else: + maxbot_send_text_message(chat_id, settings_max['token'], message_text) + else: maxbot_send_text_message(chat_id, settings_max['token'], message_text) # всегда отправляем меню после ответа maxbot_send_menu_button(chat_id=chat_id, max_token=settings_max['token'], message='👇 Выберите раздел', client=client) - return True + return True \ No newline at end of file diff --git a/restoran_max_bot/restoran_max_bot/common.py b/restoran_max_bot/restoran_max_bot/common.py index d2271f1..cedbfd7 100644 --- a/restoran_max_bot/restoran_max_bot/common.py +++ b/restoran_max_bot/restoran_max_bot/common.py @@ -1,6 +1,8 @@ # функция поиска контента по ключевому слову import json +import requests + from max_bot.models import Client, ProductCategory, Product, Contact, Integration, Partner @@ -69,3 +71,20 @@ def common_get_integration(client: Client) -> Integration: partner = Partner.objects.filter(status=1, slug__in=['iiko', 'rkeeper']) integration = Integration.objects.filter(client=client, status=1, partner__in=partner).first() return integration + + +def send_message_all_manager(client: Client, message: str): + """ + Функция отправки сообщений в групу администраторам + """ + + headers = { + 'Content-Type': 'application/json', + } + + url = f'https://api.telefon-ip.ru/webast/sendmessages/{client.token}?messages={message}' + print(url) + response = requests.request("GET", url, headers=headers).json() + return response + +