|
|
|
|
@ -3,7 +3,7 @@ import os
|
|
|
|
|
import json
|
|
|
|
|
import re
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
from typing import Dict, Any, Optional
|
|
|
|
|
from typing import Dict, Any, List, 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,7 +30,6 @@ 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
|
|
|
|
|
|
|
|
|
|
@ -52,7 +51,6 @@ 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
|
|
|
|
|
@ -73,65 +71,56 @@ def _load_base_prompt() -> str:
|
|
|
|
|
return _BASE_PROMPT_CACHE
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
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 'Не указана'}
|
|
|
|
|
"""
|
|
|
|
|
cache.set(cache_key, base_prompt, 300) # 5 минут
|
|
|
|
|
|
|
|
|
|
# Добавляем имя, если оно валидно
|
|
|
|
|
if name and is_valid_name(name):
|
|
|
|
|
return f"Обращайся к клиенту по имени: {name}. Используй обращение в каждом ответе.\n\n{base_prompt}"
|
|
|
|
|
prompt += restaurant_info
|
|
|
|
|
else:
|
|
|
|
|
return base_prompt
|
|
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
prompt += """
|
|
|
|
|
При ответах на вопросы о меню используй только те блюда, которые перечислены выше.
|
|
|
|
|
Если клиент спрашивает о блюде, которого нет в списке, честно скажи об этом и предложи посмотреть другие позиции.
|
|
|
|
|
Всегда помни, что ты должен возвращать только JSON, без лишнего текста вне JSON.
|
|
|
|
|
"""
|
|
|
|
|
cache.set(cache_key, prompt, 300)
|
|
|
|
|
return 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()
|
|
|
|
|
@ -158,7 +147,6 @@ 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:
|
|
|
|
|
@ -177,12 +165,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.pk), 'ERROR',
|
|
|
|
|
_log_dialogue(str(client.id), 'ERROR',
|
|
|
|
|
f"AI agent {agent_slug} missing settings: {missing}")
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
if not integration.token:
|
|
|
|
|
_log_dialogue(str(client.pk), 'ERROR',
|
|
|
|
|
_log_dialogue(str(client.id), 'ERROR',
|
|
|
|
|
f"AI agent {agent_slug} missing token")
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
@ -205,10 +193,7 @@ def get_ai_agent(client: Client) -> Dict[str, Optional[str]]:
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def call_ai_agent(client: Client, session_id: str, user_query: str, contact_name: str = None) -> Dict[str, Any]:
|
|
|
|
|
"""
|
|
|
|
|
Универсальный вызов AI-агента с передачей имени клиента.
|
|
|
|
|
"""
|
|
|
|
|
def call_ai_agent(client: Client, session_id: str, user_query: str) -> Dict[str, Any]:
|
|
|
|
|
agent_info = get_ai_agent(client)
|
|
|
|
|
ai_agent = agent_info.get('ai_agent')
|
|
|
|
|
if not ai_agent:
|
|
|
|
|
@ -241,7 +226,7 @@ def call_ai_agent(client: Client, session_id: str, user_query: str, contact_name
|
|
|
|
|
"status": "complete"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
system_prompt = get_system_prompt(client, name=contact_name)
|
|
|
|
|
system_prompt = get_system_prompt(client)
|
|
|
|
|
client_prompt = agent_info.get('client_prompt')
|
|
|
|
|
if client_prompt:
|
|
|
|
|
system_prompt += f"\n\nДополнительная информация о ресторане:\n{client_prompt}"
|
|
|
|
|
@ -263,9 +248,11 @@ def call_ai_agent(client: Client, session_id: str, user_query: str, contact_name
|
|
|
|
|
"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:
|
|
|
|
|
@ -279,36 +266,3 @@ def call_ai_agent(client: Client, session_id: str, user_query: str, contact_name
|
|
|
|
|
"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
|
|
|
|
|
|