diff --git a/myproject/products/services/__init__.py b/myproject/products/services/__init__.py index b2ca251..471c00e 100644 --- a/myproject/products/services/__init__.py +++ b/myproject/products/services/__init__.py @@ -1,9 +1,11 @@ """ Сервисы для бизнес-логики products приложения. -Следует принципу "Skinny Models, Fat Services". +Следует принципу "Тонкие модели, толстые сервисы". """ from .unit_service import UnitOfMeasureService +from .ai.bouquet_names import BouquetNameGenerator __all__ = [ 'UnitOfMeasureService', + 'BouquetNameGenerator', ] diff --git a/myproject/products/services/ai/__init__.py b/myproject/products/services/ai/__init__.py new file mode 100644 index 0000000..2fae4b8 --- /dev/null +++ b/myproject/products/services/ai/__init__.py @@ -0,0 +1,6 @@ +""" +AI-сервисы для products приложения. + +Содержит инструменты для взаимодействия с нейросетями для решения специфичных +бизнес-задач, таких как генерация названий продуктов, описаний, классификация и т.д. +""" diff --git a/myproject/products/services/ai/base.py b/myproject/products/services/ai/base.py new file mode 100644 index 0000000..8cb4d68 --- /dev/null +++ b/myproject/products/services/ai/base.py @@ -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 diff --git a/myproject/products/services/ai/bouquet_names.py b/myproject/products/services/ai/bouquet_names.py new file mode 100644 index 0000000..44a7757 --- /dev/null +++ b/myproject/products/services/ai/bouquet_names.py @@ -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 diff --git a/myproject/products/tests/test_ai_bouquet_names.py b/myproject/products/tests/test_ai_bouquet_names.py new file mode 100644 index 0000000..918086e --- /dev/null +++ b/myproject/products/tests/test_ai_bouquet_names.py @@ -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)