feat(products): добавить генератор названий букетов с ai и тесты

- Добавить модуль ai с генератором названий букетов
- Обновить __init__.py для экспорта нового сервиса
- Добавить тесты для проверки работы генератора
This commit is contained in:
2026-01-22 12:12:57 +03:00
parent 036b9d1634
commit 01873be15d
5 changed files with 356 additions and 1 deletions

View File

@@ -1,9 +1,11 @@
"""
Сервисы для бизнес-логики products приложения.
Следует принципу "Skinny Models, Fat Services".
Следует принципу "Тонкие модели, толстые сервисы".
"""
from .unit_service import UnitOfMeasureService
from .ai.bouquet_names import BouquetNameGenerator
__all__ = [
'UnitOfMeasureService',
'BouquetNameGenerator',
]

View File

@@ -0,0 +1,6 @@
"""
AI-сервисы для products приложения.
Содержит инструменты для взаимодействия с нейросетями для решения специфичных
бизнес-задач, таких как генерация названий продуктов, описаний, классификация и т.д.
"""

View File

@@ -0,0 +1,48 @@
from abc import ABC, abstractmethod
from typing import Tuple, Optional, Dict
from integrations.services.ai_services.glm_service import GLMIntegrationService
from integrations.services.ai_services.openrouter_service import OpenRouterIntegrationService
from integrations.models.ai_services.glm import GLMIntegration
from integrations.models.ai_services.openrouter import OpenRouterIntegration
import logging
logger = logging.getLogger(__name__)
class BaseAIProductService(ABC):
"""
Абстрактный базовый класс для AI-сервисов продуктов
"""
@abstractmethod
def generate(self, **kwargs) -> Tuple[bool, str, Optional[Dict]]:
"""
Основной метод генерации
"""
pass
@classmethod
def get_glm_service(cls) -> Optional[GLMIntegrationService]:
"""
Получить сервис GLM из активной интеграции
"""
try:
integration = GLMIntegration.objects.filter(is_active=True).first()
if integration:
return GLMIntegrationService(integration)
except Exception as e:
logger.error(f"Ошибка при получении GLM сервиса: {str(e)}")
return None
@classmethod
def get_openrouter_service(cls) -> Optional[OpenRouterIntegrationService]:
"""
Получить сервис OpenRouter из активной интеграции
"""
try:
integration = OpenRouterIntegration.objects.filter(is_active=True).first()
if integration:
return OpenRouterIntegrationService(integration)
except Exception as e:
logger.error(f"Ошибка при получении OpenRouter сервиса: {str(e)}")
return None

View File

@@ -0,0 +1,130 @@
from typing import Tuple, Optional, Dict, List
from .base import BaseAIProductService
import logging
logger = logging.getLogger(__name__)
class BouquetNameGenerator(BaseAIProductService):
"""
Сервис для генерации названий букетов с помощью AI
"""
DEFAULT_SYSTEM_PROMPT = (
"Вы эксперт в создании красивых, привлекательных и продаваемых названий для букетов цветов. "
"Ваша цель — генерировать запоминающиеся и выразительные названия, которые привлекут покупателей. "
"Названия должны быть краткими (2-4 слов), креативными и соответствующими характеристикам букета. "
"Избегайте общих терминов. Фокусируйтесь на эмоциях, эстетике"
)
def generate(
self,
count: int = 500,
characteristics: Optional[str] = None,
occasion: Optional[str] = None,
language: str = "russian"
) -> Tuple[bool, str, Optional[Dict]]:
"""
Генерация названий букетов
Args:
count: Количество названий для генерации
characteristics: Характеристики букетов (например, "розы, лилии, яркий")
occasion: П'occasion (например, "день рождения, Valentine's Day")
language: Язык генерации
Returns:
Tuple: (success, message, data) где data содержит список названий
"""
logger.info(f"Генерация {count} названий для букетов")
# Получаем доступный AI-сервис
glm_service = self.get_glm_service()
if not glm_service:
openrouter_service = self.get_openrouter_service()
if not openrouter_service:
return False, "Нет активных AI-интеграций", None
service = openrouter_service
else:
service = glm_service
# Формируем промпт
prompt = f"Сгенерируй {count} креативных и привлекательных названий для букетов цветов"
if characteristics:
prompt += f" с следующими характеристиками: {characteristics}"
if occasion:
prompt += f" для праздника: {occasion}"
prompt += (
"\n\nТребования к каждому названию:\n"
"- Точно 2-4 слова\n"
"- Выразительные и эмоциональные\n"
"- Продаваемые и запоминающиеся\n"
"- Избегайте общих названий типа 'Букет #1'\n"
"- Фокусируйтесь на красоте, романтике и подарках\n"
"\nВерните названия в виде нумерованного списка, по одному на строку."
)
# Вызов AI-сервиса
success, msg, response = service.generate_text(
prompt=prompt,
system_prompt=self.DEFAULT_SYSTEM_PROMPT,
max_tokens=3000 # Увеличиваем лимит для большего числа названий
)
if not success:
return False, msg, None
# Парсим результат
names = self._parse_response(response.get('generated_text', ''))
return True, f"Сгенерировано {len(names)} названий для букетов", {
'names': names,
'model': response.get('model'),
'usage': response.get('usage')
}
def _parse_response(self, text: str) -> List[str]:
"""
Парсит текстовый ответ AI и извлекает названия букетов
"""
names = []
lines = text.split('\n')
for line in lines:
line = line.strip()
# Пропускаем пустые строки и заголовки
if not line or line.lower().startswith('here') or line.lower().startswith('names') or \
line.lower().startswith('i\'m') or line.lower().startswith('sorry') or \
line.lower().startswith('i hope') or line.lower().startswith('hope'):
continue
# Удаляем номера списка
if line and (line[0].isdigit() or line[0] == '-'):
# Удаляем номер и точку или дефис
if '.' in line:
line = line.split('.', 1)[1].strip()
else:
line = line[1:].strip()
# Пропускаем строки, которые стали пустыми после удаления номера
if not line:
continue
# Удаляем markdown форматирование (жирный, курсив)
line = line.replace('**', '').replace('*', '').replace('"', '').replace("'", '').strip()
if line:
names.append(line)
# Удаляем дубликаты
unique_names = []
seen = set()
for name in names:
if name not in seen:
seen.add(name)
unique_names.append(name)
return unique_names

View File

@@ -0,0 +1,169 @@
from django_tenants.test.cases import TenantTestCase
from products.services import BouquetNameGenerator
from unittest.mock import patch, MagicMock
class BouquetNameGeneratorTestCase(TenantTestCase):
"""
Тесты для сервиса генерации названий букетов
"""
def setUp(self):
"""
Создаем экземпляр сервиса для тестирования
"""
self.generator = BouquetNameGenerator()
@patch('products.services.ai.bouquet_names.BouquetNameGenerator.get_glm_service')
def test_generate_with_mock_glm(self, mock_get_glm_service):
"""
Тест генерации названий с мок-объектом GLM сервиса
"""
# Создаем мок-объект сервиса
mock_service = MagicMock()
mock_service.generate_text.return_value = (
True,
"Текст успешно сгенерирован",
{
'generated_text': (
"1. Розавая мечта\n"
"2. Лиловые настроения\n"
"3. Яркий букет для дня рождения\n"
"4. Сладкий сюрприз\n"
"5. Романтическое вдохновение"
),
'model': 'glm-4',
'usage': {'prompt_tokens': 100, 'completion_tokens': 50}
}
)
mock_get_glm_service.return_value = mock_service
# Вызываем метод генерации
success, msg, data = self.generator.generate(count=5)
# Проверки
self.assertTrue(success)
self.assertIn("Сгенерировано 5 названий для букетов", msg)
self.assertIsNotNone(data)
self.assertIn('names', data)
self.assertEqual(len(data['names']), 5)
self.assertEqual(data['model'], 'glm-4')
self.assertIn('usage', data)
# Проверяем, что названия содержат нужные слова
expected_names = [
"Розавая мечта",
"Лиловые настроения",
"Яркий букет для дня рождения",
"Сладкий сюрприз",
"Романтическое вдохновение"
]
self.assertEqual(data['names'], expected_names)
@patch('products.services.ai.bouquet_names.BouquetNameGenerator.get_glm_service')
@patch('products.services.ai.bouquet_names.BouquetNameGenerator.get_openrouter_service')
def test_no_active_integration(self, mock_get_openrouter, mock_get_glm):
"""
Тест случая, когда нет активных интеграций
"""
mock_get_glm.return_value = None
mock_get_openrouter.return_value = None
success, msg, data = self.generator.generate(count=10)
self.assertFalse(success)
self.assertEqual(msg, "Нет активных AI-интеграций")
self.assertIsNone(data)
@patch('products.services.ai.bouquet_names.BouquetNameGenerator.get_glm_service')
def test_generate_with_characteristics(self, mock_get_glm_service):
"""
Тест генерации с характеристиками
"""
# Создаем мок-объект сервиса
mock_service = MagicMock()
mock_service.generate_text.return_value = (
True,
"Текст успешно сгенерирован",
{
'generated_text': (
"1. Ромашковое небо\n"
"2. Лавандовый спокойствие\n"
"3. Свежие ароматы\n"
"4. Милая композиция\n"
"5. Нежный букет"
),
'model': 'glm-4',
'usage': {'prompt_tokens': 120, 'completion_tokens': 45}
}
)
mock_get_glm_service.return_value = mock_service
success, msg, data = self.generator.generate(
count=5,
characteristics="ромашки, лаванда, свежие",
occasion="день матери"
)
self.assertTrue(success)
self.assertIn("Сгенерировано 5 названий для букетов", msg)
self.assertEqual(len(data['names']), 5)
# Проверяем, что сервис был вызван с нужными параметрами
mock_service.generate_text.assert_called_once()
def test_parse_response_with_markdown(self):
"""
Тест парсинга ответа с Markdown форматированием
"""
response_text = """
Here are 3 beautiful bouquet names for you:
1. **Spring Blossom Delight**
2. *Romantic Rose Elegance*
3. "Sunny Daisy Joy"
I hope you love these!
"""
names = self.generator._parse_response(response_text)
self.assertEqual(len(names), 3)
self.assertEqual(names[0], "Spring Blossom Delight")
self.assertEqual(names[1], "Romantic Rose Elegance")
self.assertEqual(names[2], "Sunny Daisy Joy")
def test_parse_response_with_duplicates(self):
"""
Тест парсинга ответа с дубликатами
"""
response_text = """
1. Розавая мечта
2. Лиловые настроения
3. Розавая мечта
4. Сладкий сюрприз
5. Лиловые настроения
"""
names = self.generator._parse_response(response_text)
self.assertEqual(len(names), 3)
self.assertIn("Розавая мечта", names)
self.assertIn("Лиловые настроения", names)
self.assertIn("Сладкий сюрприз", names)
def test_parse_response_empty(self):
"""
Тест парсинга пустого ответа
"""
response_text = """
"""
names = self.generator._parse_response(response_text)
self.assertEqual(len(names), 0)
def test_parse_response_no_names(self):
"""
Тест парсинга ответа без названий
"""
response_text = """
I'm sorry, but I can't help with that right now.
"""
names = self.generator._parse_response(response_text)
self.assertEqual(len(names), 0)