From b56dc44530a7f7d5715877e98bdf3ac3a356e6cc Mon Sep 17 00:00:00 2001 From: root Date: Sun, 23 Nov 2025 18:50:59 +0500 Subject: [PATCH] . --- marketing-analysis-api-frontend.md | 551 ++++++++++++++++++ marketing-analysis-api.md | 358 ++++++++++++ .../controller/MarketingController.java | 127 ++++ .../kz/konturai/parser/dto/ErrorResponse.java | 47 ++ .../parser/dto/MarketingAnalysisRequest.java | 69 +++ .../parser/dto/MarketingAnalysisResponse.java | 71 +++ .../parser/dto/MarketingAnalysisResult.java | 183 ++++++ .../parser/model/MarketingAnalysis.java | 157 +++++ .../MarketingAnalysisRepository.java | 12 + .../service/MarketingAnalysisService.java | 394 +++++++++++++ .../parser/validator/ClientTypeValidator.java | 28 + .../parser/validator/ValidClientType.java | 21 + 12 files changed, 2018 insertions(+) create mode 100644 marketing-analysis-api-frontend.md create mode 100644 marketing-analysis-api.md create mode 100644 src/main/java/kz/konturai/parser/controller/MarketingController.java create mode 100644 src/main/java/kz/konturai/parser/dto/ErrorResponse.java create mode 100644 src/main/java/kz/konturai/parser/dto/MarketingAnalysisRequest.java create mode 100644 src/main/java/kz/konturai/parser/dto/MarketingAnalysisResponse.java create mode 100644 src/main/java/kz/konturai/parser/dto/MarketingAnalysisResult.java create mode 100644 src/main/java/kz/konturai/parser/model/MarketingAnalysis.java create mode 100644 src/main/java/kz/konturai/parser/repository/MarketingAnalysisRepository.java create mode 100644 src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java create mode 100644 src/main/java/kz/konturai/parser/validator/ClientTypeValidator.java create mode 100644 src/main/java/kz/konturai/parser/validator/ValidClientType.java diff --git a/marketing-analysis-api-frontend.md b/marketing-analysis-api-frontend.md new file mode 100644 index 0000000..01f5154 --- /dev/null +++ b/marketing-analysis-api-frontend.md @@ -0,0 +1,551 @@ +# API Документация: Маркетинговый анализ (Frontend/AI Agent) + +## Базовый URL + +``` +https://api.konturai.kz +``` + +## Обзор + +API для генерации маркетингового анализа на основе данных о бизнесе. Процесс состоит из двух этапов: + +1. **Запуск анализа** - создание задачи и начало асинхронной обработки +2. **Получение результатов** - проверка статуса и получение готового отчета + +Анализ выполняется асинхронно и занимает примерно 5-10 минут. + +--- + +## Эндпоинты + +### 1. Запуск маркетингового анализа + +**POST** `/api/marketing/analysis/start` + +Создает новую задачу на генерацию маркетингового анализа и запускает асинхронную обработку. + +#### Заголовки запроса + +``` +Content-Type: application/json +``` + +#### Тело запроса (JSON) + +| Поле | Тип | Обязательный | Описание | Пример значения | +| ---------------- | ------ | ------------ | ------------------------------ | -------------------------------- | +| `product` | string | ✅ | Название продукта или услуги | "Веб-разработка" | +| `location` | string | ✅ | Географическая локация работы | "Алматы, Казахстан" | +| `client` | string | ✅ | Тип целевой аудитории | "B2B клиенты" | +| `differentiator` | string | ✅ | Уникальные особенности бизнеса | "Быстрая разработка за 2 недели" | + +#### Валидация полей + +**`product`** (string, обязательное) + +- Минимальная длина: 3 символа +- Максимальная длина: 200 символов +- Разрешены: буквы, цифры, пробелы, дефисы, запятые + +**`location`** (string, обязательное) + +- Минимальная длина: 2 символа +- Максимальная длина: 150 символов +- Разрешены: буквы, цифры, пробелы, запятые, дефисы + +**`client`** (string, обязательное) + +- Допустимые значения (точно): + - `"B2B клиенты"` + - `"B2C клиенты"` + - `"Частные лица"` + - `"Корпорации"` + - `"Малый бизнес"` + +**`differentiator`** (string, обязательное) + +- Минимальная длина: 10 символов +- Максимальная длина: 500 символов +- Разрешены любые символы + +#### Пример запроса + +```json +{ + "product": "Разработка мобильных приложений", + "location": "Нур-Султан, Казахстан", + "client": "B2B клиенты", + "differentiator": "Специализируемся на быстрой разработке MVP за 4 недели с использованием современных технологий" +} +``` + +#### Пример успешного ответа (200 OK) + +```json +{ + "success": true, + "message": "Анализ запущен успешно", + "data": { + "analysisId": "507f1f77bcf86cd799439011", + "status": "processing", + "estimatedCompletionTime": "2025-01-20T15:38:00", + "message": "Анализ запущен успешно. Результаты будут готовы в течение 5-10 минут." + } +} +``` + +#### Структура ответа + +| Поле | Тип | Описание | +| ------------------------------ | ------- | --------------------------------------------------------- | +| `success` | boolean | Флаг успешности операции | +| `message` | string | Сообщение о результате операции | +| `data.analysisId` | string | Уникальный идентификатор анализа (MongoDB ObjectId) | +| `data.status` | string | Статус анализа: `"processing"` | +| `data.estimatedCompletionTime` | string | ISO 8601 дата/время ожидаемого завершения (LocalDateTime) | +| `data.message` | string | Информационное сообщение для пользователя | + +#### Пример ошибки валидации (400 Bad Request) + +```json +{ + "success": false, + "message": "Ошибка валидации", + "error": { + "code": "VALIDATION_ERROR", + "message": "Ошибка валидации входных данных", + "details": { + "product": "Поле 'product' должно содержать от 3 до 200 символов", + "client": "Поле 'client' должно быть одним из: B2B клиенты, B2C клиенты, Частные лица, Корпорации, Малый бизнес" + } + } +} +``` + +--- + +### 2. Получение результата анализа + +**GET** `/api/marketing/analysis/{analysisId}` + +Возвращает статус и результаты анализа по идентификатору. + +#### Параметры пути + +| Параметр | Тип | Описание | +| ------------ | ------ | --------------------- | +| `analysisId` | string | Идентификатор анализа | + +#### Пример запроса + +``` +GET /api/marketing/analysis/507f1f77bcf86cd799439011 +``` + +#### Пример ответа (когда анализ завершен - 200 OK) + +```json +{ + "success": true, + "message": "Операция выполнена успешно", + "data": { + "analysisId": "507f1f77bcf86cd799439011", + "status": "completed", + "createdAt": "2025-01-20T15:30:00", + "completedAt": "2025-01-20T15:38:00", + "report": { + "summary": "Краткое резюме анализа...", + "targetAudience": { + "description": "Описание целевой аудитории...", + "channels": ["Instagram", "LinkedIn", "Telegram"] + }, + "recommendations": ["Рекомендация 1", "Рекомендация 2", "Рекомендация 3"], + "strategy": { + "duration": "2 недели", + "channels": ["Instagram", "Telegram", "21MC"], + "contentTypes": ["посты", "сторис", "баннеры"] + }, + "pdfUrl": "/api/marketing/analysis/507f1f77bcf86cd799439011/download" + } + } +} +``` + +#### Пример ответа (когда анализ еще обрабатывается - 200 OK) + +```json +{ + "success": true, + "message": "Операция выполнена успешно", + "data": { + "analysisId": "507f1f77bcf86cd799439011", + "status": "processing", + "createdAt": "2025-01-20T15:30:00", + "completedAt": null, + "report": null + } +} +``` + +#### Статусы анализа + +| Статус | Описание | +| ------------ | ----------------------------- | +| `queued` | Запрос в очереди на обработку | +| `processing` | Анализ выполняется | +| `completed` | Анализ завершен успешно | +| `failed` | Анализ завершился с ошибкой | + +#### Структура ответа + +| Поле | Тип | Описание | +| ---------------------------------------- | ------- | ------------------------------------------------------ | +| `success` | boolean | Флаг успешности операции | +| `message` | string | Сообщение о результате операции | +| `data.analysisId` | string | Уникальный идентификатор анализа | +| `data.status` | string | Статус анализа | +| `data.createdAt` | string | ISO 8601 дата/время создания | +| `data.completedAt` | string | ISO 8601 дата/время завершения (null если не завершен) | +| `data.report` | object | Объект с результатами (null если не завершен) | +| `data.report.summary` | string | Краткое резюме анализа | +| `data.report.targetAudience` | object | Информация о целевой аудитории | +| `data.report.targetAudience.description` | string | Описание целевой аудитории | +| `data.report.targetAudience.channels` | array | Список рекомендуемых каналов | +| `data.report.recommendations` | array | Список рекомендаций (массив строк) | +| `data.report.strategy` | object | Маркетинговая стратегия | +| `data.report.strategy.duration` | string | Длительность кампании (например, "2 недели") | +| `data.report.strategy.channels` | array | Каналы коммуникации (массив строк) | +| `data.report.strategy.contentTypes` | array | Типы контента (массив строк) | +| `data.report.pdfUrl` | string | URL для скачивания PDF отчета | + +#### Пример ошибки (404 Not Found) + +```json +{ + "success": false, + "message": "Анализ не найден", + "error": { + "code": "NOT_FOUND", + "message": "Анализ с указанным ID не найден" + } +} +``` + +--- + +### 3. Скачивание PDF отчета + +**GET** `/api/marketing/analysis/{analysisId}/download` + +Возвращает PDF файл с полным маркетинговым отчетом. + +#### Параметры пути + +| Параметр | Тип | Описание | +| ------------ | ------ | --------------------- | +| `analysisId` | string | Идентификатор анализа | + +#### Пример запроса + +``` +GET /api/marketing/analysis/507f1f77bcf86cd799439011/download +``` + +#### Успешный ответ (200 OK) + +- **Content-Type**: `application/pdf` +- **Content-Disposition**: `attachment; filename="marketing_analysis_507f1f77bcf86cd799439011_1234567890.pdf"` +- **Body**: Бинарные данные PDF файла + +#### Ошибки + +- **404 Not Found** - Анализ не найден или PDF еще не сгенерирован +- **500 Internal Server Error** - Ошибка при получении файла + +--- + +## Обработка ошибок + +### Коды ошибок + +| Код | HTTP статус | Описание | +| ----------------------- | ----------- | ------------------------------- | +| `VALIDATION_ERROR` | 400 | Ошибка валидации входных данных | +| `NOT_FOUND` | 404 | Ресурс не найден | +| `INTERNAL_SERVER_ERROR` | 500 | Внутренняя ошибка сервера | + +### Формат ошибки + +```json +{ + "success": false, + "message": "Описание ошибки", + "error": { + "code": "ERROR_CODE", + "message": "Детальное сообщение об ошибке", + "details": { + "field1": "Сообщение об ошибке для поля 1", + "field2": "Сообщение об ошибке для поля 2" + } + } +} +``` + +**Примечание**: Поле `details` присутствует только для ошибок валидации (`VALIDATION_ERROR`). + +--- + +## Примеры использования + +### JavaScript/TypeScript (Fetch API) + +#### Запуск анализа + +```javascript +async function startMarketingAnalysis(data) { + const response = await fetch( + 'https://api.konturai.kz/api/marketing/analysis/start', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + product: 'Разработка мобильных приложений', + location: 'Нур-Султан, Казахстан', + client: 'B2B клиенты', + differentiator: + 'Специализируемся на быстрой разработке MVP за 4 недели', + }), + } + ); + + const result = await response.json(); + + if (result.success) { + console.log('Analysis ID:', result.data.analysisId); + return result.data.analysisId; + } else { + console.error('Error:', result.error); + throw new Error(result.error.message); + } +} +``` + +#### Проверка статуса и получение результата + +```javascript +async function getAnalysisResult(analysisId) { + const response = await fetch( + `https://api.konturai.kz/api/marketing/analysis/${analysisId}` + ); + + const result = await response.json(); + + if (result.success) { + const { status, report } = result.data; + + if (status === 'completed' && report) { + console.log('Analysis completed!'); + console.log('Summary:', report.summary); + console.log('Recommendations:', report.recommendations); + return report; + } else if (status === 'processing') { + console.log('Analysis is still processing...'); + return null; // Повторить запрос позже + } else if (status === 'failed') { + throw new Error('Analysis failed'); + } + } else { + throw new Error(result.error.message); + } +} +``` + +#### Полный цикл с polling + +```javascript +async function waitForAnalysisCompletion( + analysisId, + maxAttempts = 60, + intervalMs = 10000 +) { + for (let i = 0; i < maxAttempts; i++) { + const result = await getAnalysisResult(analysisId); + + if (result) { + return result; // Анализ завершен + } + + // Ждем перед следующей проверкой + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + throw new Error('Analysis timeout'); +} + +// Использование +async function runFullAnalysis() { + try { + // 1. Запускаем анализ + const analysisId = await startMarketingAnalysis({ + product: 'Веб-разработка', + location: 'Алматы, Казахстан', + client: 'B2B клиенты', + differentiator: 'Быстрая разработка за 2 недели', + }); + + console.log(`Analysis started: ${analysisId}`); + + // 2. Ждем завершения (проверяем каждые 10 секунд, максимум 10 минут) + const report = await waitForAnalysisCompletion(analysisId, 60, 10000); + + // 3. Используем результаты + console.log('Report summary:', report.summary); + console.log('Channels:', report.targetAudience.channels); + console.log('PDF URL:', report.pdfUrl); + + return report; + } catch (error) { + console.error('Error:', error); + } +} +``` + +#### Скачивание PDF + +```javascript +async function downloadPdf(analysisId) { + const response = await fetch( + `https://api.konturai.kz/api/marketing/analysis/${analysisId}/download` + ); + + if (!response.ok) { + throw new Error('Failed to download PDF'); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `marketing_analysis_${analysisId}.pdf`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); +} +``` + +### Python + +```python +import requests +import time + +BASE_URL = "https://api.konturai.kz" + +def start_analysis(product, location, client, differentiator): + response = requests.post( + f"{BASE_URL}/api/marketing/analysis/start", + json={ + "product": product, + "location": location, + "client": client, + "differentiator": differentiator + } + ) + response.raise_for_status() + data = response.json() + + if data["success"]: + return data["data"]["analysisId"] + else: + raise Exception(data["error"]["message"]) + +def get_analysis_result(analysis_id): + response = requests.get( + f"{BASE_URL}/api/marketing/analysis/{analysis_id}" + ) + response.raise_for_status() + return response.json()["data"] + +def wait_for_completion(analysis_id, max_attempts=60, interval=10): + for _ in range(max_attempts): + result = get_analysis_result(analysis_id) + + if result["status"] == "completed": + return result["report"] + elif result["status"] == "failed": + raise Exception("Analysis failed") + + time.sleep(interval) + + raise Exception("Analysis timeout") + +# Использование +analysis_id = start_analysis( + product="Разработка мобильных приложений", + location="Нур-Султан, Казахстан", + client="B2B клиенты", + differentiator="Быстрая разработка MVP за 4 недели" +) + +print(f"Analysis started: {analysis_id}") + +report = wait_for_completion(analysis_id) +print(f"Summary: {report['summary']}") +print(f"Channels: {report['targetAudience']['channels']}") +``` + +--- + +## Рекомендации по интеграции + +### 1. Polling стратегия + +Рекомендуется проверять статус анализа каждые 10-15 секунд. Максимальное время ожидания - 10-15 минут. + +### 2. Обработка ошибок + +Всегда проверяйте поле `success` в ответе и обрабатывайте ошибки соответствующим образом. + +### 3. Валидация на клиенте + +Перед отправкой запроса рекомендуется валидировать данные на клиенте: + +- Проверка длины полей +- Проверка допустимых значений для `client` +- Проверка обязательных полей + +### 4. UX рекомендации + +- Показывайте индикатор загрузки во время обработки +- Отображайте примерное время завершения +- Предоставьте возможность отменить ожидание и проверить результат позже +- Сохраняйте `analysisId` для последующей проверки статуса + +### 5. Кэширование + +После получения результатов можно кэшировать их локально, используя `analysisId` как ключ. + +--- + +## Примечания + +1. **Формат даты**: Все даты возвращаются в формате ISO 8601 без timezone (LocalDateTime) +2. **Идентификаторы**: Используются MongoDB ObjectId (24 символа hex) +3. **Асинхронность**: Анализ выполняется асинхронно, не блокируя запрос +4. **Таймауты**: Рекомендуется устанавливать таймаут на запросы (минимум 30 секунд для запуска анализа) +5. **Rate Limiting**: В будущем может быть добавлено ограничение на количество запросов + +--- + +## Поддержка + +При возникновении проблем с API обращайтесь в техническую поддержку с указанием: + +- `analysisId` (если есть) +- Время запроса +- Описание проблемы +- Код ошибки (если есть) diff --git a/marketing-analysis-api.md b/marketing-analysis-api.md new file mode 100644 index 0000000..5583777 --- /dev/null +++ b/marketing-analysis-api.md @@ -0,0 +1,358 @@ +# API Документация: Маркетинговый анализ + +## Обзор + +API для запуска маркетингового анализа на основе данных о бизнесе пользователя. Эндпоинт принимает информацию о продукте, локации, типе клиентов и уникальных особенностях бизнеса, затем генерирует маркетинговый отчет. + +## Эндпоинт + +**POST** `/api/marketing/analysis/start` + +### Базовый URL + +``` +https://api.konturai.kz/api/marketing/analysis/start +``` + +## Запрос + +### Заголовки + +``` +Content-Type: application/json +Authorization: Bearer {access_token} // Опционально, если требуется аутентификация +``` + +### Тело запроса (JSON) + +| Поле | Тип | Обязательный | Описание | Пример значения | +| ---------------- | ------ | ------------ | ------------------------------ | -------------------------------- | +| `product` | string | ✅ | Название продукта или услуги | "Веб-разработка" | +| `location` | string | ✅ | Географическая локация работы | "Алматы, Казахстан" | +| `client` | string | ✅ | Тип целевой аудитории | "B2B клиенты" | +| `differentiator` | string | ✅ | Уникальные особенности бизнеса | "Быстрая разработка за 2 недели" | + +### Валидация полей + +#### `product` (string, обязательное) + +- **Минимальная длина**: 3 символа +- **Максимальная длина**: 200 символов +- **Паттерн**: Разрешены буквы, цифры, пробелы, дефисы, запятые +- **Ошибка валидации**: `"product": "Поле 'product' должно содержать от 3 до 200 символов"` + +#### `location` (string, обязательное) + +- **Минимальная длина**: 2 символа +- **Максимальная длина**: 150 символов +- **Паттерн**: Разрешены буквы, цифры, пробелы, запятые, дефисы +- **Ошибка валидации**: `"location": "Поле 'location' должно содержать от 2 до 150 символов"` + +#### `client` (string, обязательное) + +- **Допустимые значения**: + - `"B2B клиенты"` + - `"B2C клиенты"` + - `"Частные лица"` + - `"Корпорации"` + - `"Малый бизнес"` +- **Ошибка валидации**: `"client": "Поле 'client' должно быть одним из: B2B клиенты, B2C клиенты, Частные лица, Корпорации, Малый бизнес"` + +#### `differentiator` (string, обязательное) + +- **Минимальная длина**: 10 символов +- **Максимальная длина**: 500 символов +- **Паттерн**: Разрешены любые символы +- **Ошибка валидации**: `"differentiator": "Поле 'differentiator' должно содержать от 10 до 500 символов"` + +### Пример запроса + +```json +{ + "product": "Разработка мобильных приложений", + "location": "Нур-Султан, Казахстан", + "client": "B2B клиенты", + "differentiator": "Специализируемся на быстрой разработке MVP за 4 недели с использованием современных технологий" +} +``` + +## Ответ + +### Успешный ответ (200 OK) + +```json +{ + "success": true, + "data": { + "analysisId": "550e8400-e29b-41d4-a716-446655440000", + "status": "processing", + "estimatedCompletionTime": "2025-01-20T15:30:00Z", + "message": "Анализ запущен успешно. Результаты будут готовы в течение 5-10 минут." + } +} +``` + +#### Поля ответа + +| Поле | Тип | Описание | +| ------------------------------ | ------- | --------------------------------------------------------- | +| `success` | boolean | Флаг успешности операции | +| `data.analysisId` | string | Уникальный идентификатор анализа (UUID) | +| `data.status` | string | Статус анализа: `"processing"`, `"completed"`, `"failed"` | +| `data.estimatedCompletionTime` | string | ISO 8601 дата/время ожидаемого завершения анализа | +| `data.message` | string | Информационное сообщение для пользователя | + +### Асинхронная обработка (202 Accepted) + +Если анализ требует длительной обработки, сервер может вернуть статус 202: + +```json +{ + "success": true, + "data": { + "analysisId": "550e8400-e29b-41d4-a716-446655440000", + "status": "queued", + "queuePosition": 3, + "estimatedWaitTime": 300, + "message": "Запрос добавлен в очередь. Примерное время ожидания: 5 минут." + } +} +``` + +## Обработка ошибок + +### Ошибки валидации (400 Bad Request) + +```json +{ + "success": false, + "error": { + "code": "VALIDATION_ERROR", + "message": "Ошибка валидации входных данных", + "details": { + "product": "Поле 'product' обязательно для заполнения", + "client": "Недопустимое значение поля 'client'" + } + } +} +``` + +### Ошибка аутентификации (401 Unauthorized) + +```json +{ + "success": false, + "error": { + "code": "UNAUTHORIZED", + "message": "Требуется аутентификация" + } +} +``` + +### Ошибка сервера (500 Internal Server Error) + +```json +{ + "success": false, + "error": { + "code": "INTERNAL_SERVER_ERROR", + "message": "Произошла внутренняя ошибка сервера. Попробуйте позже." + } +} +``` + +### Ошибка таймаута (504 Gateway Timeout) + +```json +{ + "success": false, + "error": { + "code": "TIMEOUT", + "message": "Превышено время ожидания ответа от сервиса анализа" + } +} +``` + +## Получение результатов анализа + +После успешного запуска анализа, результаты можно получить по идентификатору: + +**GET** `/api/marketing/analysis/{analysisId}` + +### Пример запроса + +``` +GET /api/marketing/analysis/550e8400-e29b-41d4-a716-446655440000 +``` + +### Пример ответа (когда анализ завершен) + +```json +{ + "success": true, + "data": { + "analysisId": "550e8400-e29b-41d4-a716-446655440000", + "status": "completed", + "createdAt": "2025-01-20T15:00:00Z", + "completedAt": "2025-01-20T15:08:00Z", + "report": { + "summary": "Краткое резюме анализа...", + "targetAudience": { + "description": "Описание целевой аудитории...", + "channels": ["Instagram", "LinkedIn", "Telegram"] + }, + "recommendations": ["Рекомендация 1", "Рекомендация 2"], + "strategy": { + "duration": "2 недели", + "channels": ["Instagram", "Telegram", "21MC"], + "contentTypes": ["посты", "сторис", "баннеры"] + }, + "pdfUrl": "/api/marketing/analysis/550e8400-e29b-41d4-a716-446655440000/download" + } + } +} +``` + +### Статусы анализа + +- `queued` - Запрос в очереди на обработку +- `processing` - Анализ выполняется +- `completed` - Анализ завершен успешно +- `failed` - Анализ завершился с ошибкой + +## Рекомендации по реализации + +### 1. Валидация на бэкенде + +```java +// Пример валидации (Java/Spring Boot) +@PostMapping("/api/marketing/analysis/start") +public ResponseEntity startAnalysis(@Valid @RequestBody MarketingAnalysisRequest request) { + // Валидация выполняется автоматически через @Valid + // Дополнительная бизнес-логика валидации + if (!isValidClientType(request.getClient())) { + return ResponseEntity.badRequest() + .body(new ErrorResponse("VALIDATION_ERROR", "Недопустимый тип клиента")); + } + // Обработка запроса +} +``` + +### 2. Асинхронная обработка + +Рекомендуется использовать асинхронную обработку для длительных операций: + +```java +@Async +public CompletableFuture processAnalysis(MarketingAnalysisRequest request) { + // Длительная обработка + // Генерация отчета + // Сохранение результатов + return CompletableFuture.completedFuture(result); +} +``` + +### 3. Хранение данных + +Рекомендуемая структура таблицы в БД: + +```sql +CREATE TABLE marketing_analysis ( + id UUID PRIMARY KEY, + product VARCHAR(200) NOT NULL, + location VARCHAR(150) NOT NULL, + client_type VARCHAR(50) NOT NULL, + differentiator TEXT NOT NULL, + status VARCHAR(20) NOT NULL, + created_at TIMESTAMP NOT NULL, + completed_at TIMESTAMP, + user_id UUID, -- Если требуется аутентификация + report_data JSONB, -- JSON с результатами анализа + CONSTRAINT valid_client_type CHECK (client_type IN ( + 'B2B клиенты', 'B2C клиенты', 'Частные лица', 'Корпорации', 'Малый бизнес' + )), + CONSTRAINT valid_status CHECK (status IN ( + 'queued', 'processing', 'completed', 'failed' + )) +); +``` + +### 4. Интеграция с AI сервисом + +Если используется внешний AI сервис для генерации анализа: + +```java +public AnalysisResult generateAnalysis(MarketingAnalysisRequest request) { + // Подготовка промпта для AI + String prompt = String.format( + "Проанализируй бизнес:\n" + + "Продукт: %s\n" + + "Локация: %s\n" + + "Клиенты: %s\n" + + "Уникальность: %s\n" + + "Создай маркетинговую стратегию...", + request.getProduct(), + request.getLocation(), + request.getClient(), + request.getDifferentiator() + ); + + // Вызов AI API + return aiService.generateReport(prompt); +} +``` + +### 5. Обработка ошибок + +```java +@ExceptionHandler(ValidationException.class) +public ResponseEntity handleValidationException(ValidationException e) { + return ResponseEntity.badRequest() + .body(new ErrorResponse("VALIDATION_ERROR", e.getMessage(), e.getDetails())); +} + +@ExceptionHandler(Exception.class) +public ResponseEntity handleGenericException(Exception e) { + log.error("Unexpected error", e); + return ResponseEntity.status(500) + .body(new ErrorResponse("INTERNAL_SERVER_ERROR", "Внутренняя ошибка сервера")); +} +``` + +## Тестирование + +### Примеры тестовых запросов + +#### Успешный запрос + +```bash +curl -X POST https://api.konturai.kz/api/marketing/analysis/start \ + -H "Content-Type: application/json" \ + -d '{ + "product": "Разработка мобильных приложений", + "location": "Алматы, Казахстан", + "client": "B2B клиенты", + "differentiator": "Специализируемся на быстрой разработке MVP за 4 недели" + }' +``` + +#### Запрос с ошибкой валидации + +```bash +curl -X POST https://api.konturai.kz/api/marketing/analysis/start \ + -H "Content-Type: application/json" \ + -d '{ + "product": "AB", + "location": "Алматы", + "client": "Неверный тип", + "differentiator": "Коротко" + }' +``` + +## Примечания + +1. **Аутентификация**: Если требуется аутентификация, используйте JWT токен в заголовке `Authorization` +2. **Rate Limiting**: Рекомендуется ограничить количество запросов на пользователя (например, 10 запросов в час) +3. **Кэширование**: Можно кэшировать результаты для одинаковых запросов +4. **Логирование**: Все запросы должны логироваться для отладки и аналитики +5. **Мониторинг**: Отслеживайте время выполнения анализа и процент успешных завершений diff --git a/src/main/java/kz/konturai/parser/controller/MarketingController.java b/src/main/java/kz/konturai/parser/controller/MarketingController.java new file mode 100644 index 0000000..fc5575d --- /dev/null +++ b/src/main/java/kz/konturai/parser/controller/MarketingController.java @@ -0,0 +1,127 @@ +package kz.konturai.parser.controller; + +import kz.konturai.parser.dto.*; +import kz.konturai.parser.model.MarketingAnalysis; +import kz.konturai.parser.service.MarketingAnalysisService; +import kz.konturai.parser.service.MinIOService; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; +import java.io.InputStream; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +@RestController +@RequestMapping("/api/marketing/analysis") +public class MarketingController { + + private final MarketingAnalysisService marketingAnalysisService; + private final MinIOService minIOService; + + public MarketingController(MarketingAnalysisService marketingAnalysisService, MinIOService minIOService) { + this.marketingAnalysisService = marketingAnalysisService; + this.minIOService = minIOService; + } + + @PostMapping("/start") + public ResponseEntity> startAnalysis( + @Valid @RequestBody MarketingAnalysisRequest request) { + + // Create analysis record + MarketingAnalysis analysis = marketingAnalysisService.startAnalysis(request); + + // Start async processing + marketingAnalysisService.processAnalysis(analysis.getId(), request); + + // Calculate estimated completion time (5-10 minutes) + LocalDateTime estimatedCompletion = LocalDateTime.now().plusMinutes(8); + + MarketingAnalysisResponse response = new MarketingAnalysisResponse( + analysis.getId(), + "processing", + estimatedCompletion, + "Анализ запущен успешно. Результаты будут готовы в течение 5-10 минут."); + + return ResponseEntity.ok(ApiResponse.success("Анализ запущен успешно", response)); + } + + @GetMapping("/{analysisId}") + public ResponseEntity getAnalysis( + @PathVariable String analysisId) { + + MarketingAnalysisResult result = marketingAnalysisService.getAnalysisResult(analysisId); + + if (result == null) { + ErrorResponse error = new ErrorResponse( + "NOT_FOUND", + "Анализ с указанным ID не найден"); + return ResponseEntity.status(404) + .body(ApiResponse.error("Анализ не найден", error)); + } + + return ResponseEntity.ok(ApiResponse.success(result)); + } + + @GetMapping("/{analysisId}/download") + public ResponseEntity downloadPdf(@PathVariable String analysisId) { + MarketingAnalysisResult result = marketingAnalysisService.getAnalysisResult(analysisId); + + if (result == null || result.getReport() == null || result.getReport().getPdfUrl() == null) { + return ResponseEntity.notFound().build(); + } + + // Get the analysis to find PDF filename + Optional optAnalysis = marketingAnalysisService.getAnalysisById(analysisId); + if (optAnalysis.isEmpty() || optAnalysis.get().getPdfFilePath() == null) { + return ResponseEntity.notFound().build(); + } + + try { + InputStream inputStream = minIOService.downloadFile(optAnalysis.get().getPdfFilePath()); + byte[] bytes = inputStream.readAllBytes(); + inputStream.close(); + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + optAnalysis.get().getPdfFilename() + "\"") + .contentType(MediaType.APPLICATION_PDF) + .body(bytes); + } catch (Exception e) { + return ResponseEntity.internalServerError().build(); + } + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationException( + MethodArgumentNotValidException ex) { + + Map details = new HashMap<>(); + ex.getBindingResult().getFieldErrors().forEach(error -> { + details.put(error.getField(), error.getDefaultMessage()); + }); + + ErrorResponse errorResponse = new ErrorResponse( + "VALIDATION_ERROR", + "Ошибка валидации входных данных", + details); + + return ResponseEntity.badRequest() + .body(ApiResponse.error("Ошибка валидации", errorResponse)); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGenericException(Exception e) { + ErrorResponse errorResponse = new ErrorResponse( + "INTERNAL_SERVER_ERROR", + "Произошла внутренняя ошибка сервера. Попробуйте позже."); + + return ResponseEntity.status(500) + .body(ApiResponse.error("Внутренняя ошибка сервера", errorResponse)); + } +} diff --git a/src/main/java/kz/konturai/parser/dto/ErrorResponse.java b/src/main/java/kz/konturai/parser/dto/ErrorResponse.java new file mode 100644 index 0000000..5c94c18 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/ErrorResponse.java @@ -0,0 +1,47 @@ +package kz.konturai.parser.dto; + +import java.util.Map; + +public class ErrorResponse { + private String code; + private String message; + private Map details; + + public ErrorResponse() { + } + + public ErrorResponse(String code, String message) { + this.code = code; + this.message = message; + } + + public ErrorResponse(String code, String message, Map details) { + this.code = code; + this.message = message; + this.details = details; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public Map getDetails() { + return details; + } + + public void setDetails(Map details) { + this.details = details; + } +} diff --git a/src/main/java/kz/konturai/parser/dto/MarketingAnalysisRequest.java b/src/main/java/kz/konturai/parser/dto/MarketingAnalysisRequest.java new file mode 100644 index 0000000..df7b251 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/MarketingAnalysisRequest.java @@ -0,0 +1,69 @@ +package kz.konturai.parser.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import kz.konturai.parser.validator.ValidClientType; + +public class MarketingAnalysisRequest { + + @NotBlank(message = "Поле 'product' обязательно для заполнения") + @Size(min = 3, max = 200, message = "Поле 'product' должно содержать от 3 до 200 символов") + @Pattern(regexp = "^[\\p{L}\\p{N}\\s\\-,]+$", message = "Поле 'product' содержит недопустимые символы") + private String product; + + @NotBlank(message = "Поле 'location' обязательно для заполнения") + @Size(min = 2, max = 150, message = "Поле 'location' должно содержать от 2 до 150 символов") + @Pattern(regexp = "^[\\p{L}\\p{N}\\s\\-,]+$", message = "Поле 'location' содержит недопустимые символы") + private String location; + + @NotBlank(message = "Поле 'client' обязательно для заполнения") + @ValidClientType(message = "Поле 'client' должно быть одним из: B2B клиенты, B2C клиенты, Частные лица, Корпорации, Малый бизнес") + private String client; + + @NotBlank(message = "Поле 'differentiator' обязательно для заполнения") + @Size(min = 10, max = 500, message = "Поле 'differentiator' должно содержать от 10 до 500 символов") + private String differentiator; + + public MarketingAnalysisRequest() { + } + + public MarketingAnalysisRequest(String product, String location, String client, String differentiator) { + this.product = product; + this.location = location; + this.client = client; + this.differentiator = differentiator; + } + + public String getProduct() { + return product; + } + + public void setProduct(String product) { + this.product = product; + } + + public String getLocation() { + return location; + } + + public void setLocation(String location) { + this.location = location; + } + + public String getClient() { + return client; + } + + public void setClient(String client) { + this.client = client; + } + + public String getDifferentiator() { + return differentiator; + } + + public void setDifferentiator(String differentiator) { + this.differentiator = differentiator; + } +} diff --git a/src/main/java/kz/konturai/parser/dto/MarketingAnalysisResponse.java b/src/main/java/kz/konturai/parser/dto/MarketingAnalysisResponse.java new file mode 100644 index 0000000..78e234d --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/MarketingAnalysisResponse.java @@ -0,0 +1,71 @@ +package kz.konturai.parser.dto; + +import java.time.LocalDateTime; + +public class MarketingAnalysisResponse { + private String analysisId; + private String status; + private LocalDateTime estimatedCompletionTime; + private String message; + private Integer queuePosition; + private Integer estimatedWaitTime; + + public MarketingAnalysisResponse() { + } + + public MarketingAnalysisResponse(String analysisId, String status, LocalDateTime estimatedCompletionTime, + String message) { + this.analysisId = analysisId; + this.status = status; + this.estimatedCompletionTime = estimatedCompletionTime; + this.message = message; + } + + public String getAnalysisId() { + return analysisId; + } + + public void setAnalysisId(String analysisId) { + this.analysisId = analysisId; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public LocalDateTime getEstimatedCompletionTime() { + return estimatedCompletionTime; + } + + public void setEstimatedCompletionTime(LocalDateTime estimatedCompletionTime) { + this.estimatedCompletionTime = estimatedCompletionTime; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public Integer getQueuePosition() { + return queuePosition; + } + + public void setQueuePosition(Integer queuePosition) { + this.queuePosition = queuePosition; + } + + public Integer getEstimatedWaitTime() { + return estimatedWaitTime; + } + + public void setEstimatedWaitTime(Integer estimatedWaitTime) { + this.estimatedWaitTime = estimatedWaitTime; + } +} diff --git a/src/main/java/kz/konturai/parser/dto/MarketingAnalysisResult.java b/src/main/java/kz/konturai/parser/dto/MarketingAnalysisResult.java new file mode 100644 index 0000000..a242c56 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/MarketingAnalysisResult.java @@ -0,0 +1,183 @@ +package kz.konturai.parser.dto; + +import java.time.LocalDateTime; +import java.util.List; + +public class MarketingAnalysisResult { + private String analysisId; + private String status; + private LocalDateTime createdAt; + private LocalDateTime completedAt; + private MarketingReport report; + + public MarketingAnalysisResult() { + } + + public MarketingAnalysisResult(String analysisId, String status, LocalDateTime createdAt, LocalDateTime completedAt, + MarketingReport report) { + this.analysisId = analysisId; + this.status = status; + this.createdAt = createdAt; + this.completedAt = completedAt; + this.report = report; + } + + public String getAnalysisId() { + return analysisId; + } + + public void setAnalysisId(String analysisId) { + this.analysisId = analysisId; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getCompletedAt() { + return completedAt; + } + + public void setCompletedAt(LocalDateTime completedAt) { + this.completedAt = completedAt; + } + + public MarketingReport getReport() { + return report; + } + + public void setReport(MarketingReport report) { + this.report = report; + } + + public static class MarketingReport { + private String summary; + private TargetAudience targetAudience; + private List recommendations; + private Strategy strategy; + private String pdfUrl; + + public MarketingReport() { + } + + public String getSummary() { + return summary; + } + + public void setSummary(String summary) { + this.summary = summary; + } + + public TargetAudience getTargetAudience() { + return targetAudience; + } + + public void setTargetAudience(TargetAudience targetAudience) { + this.targetAudience = targetAudience; + } + + public List getRecommendations() { + return recommendations; + } + + public void setRecommendations(List recommendations) { + this.recommendations = recommendations; + } + + public Strategy getStrategy() { + return strategy; + } + + public void setStrategy(Strategy strategy) { + this.strategy = strategy; + } + + public String getPdfUrl() { + return pdfUrl; + } + + public void setPdfUrl(String pdfUrl) { + this.pdfUrl = pdfUrl; + } + } + + public static class TargetAudience { + private String description; + private List channels; + + public TargetAudience() { + } + + public TargetAudience(String description, List channels) { + this.description = description; + this.channels = channels; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public List getChannels() { + return channels; + } + + public void setChannels(List channels) { + this.channels = channels; + } + } + + public static class Strategy { + private String duration; + private List channels; + private List contentTypes; + + public Strategy() { + } + + public Strategy(String duration, List channels, List contentTypes) { + this.duration = duration; + this.channels = channels; + this.contentTypes = contentTypes; + } + + public String getDuration() { + return duration; + } + + public void setDuration(String duration) { + this.duration = duration; + } + + public List getChannels() { + return channels; + } + + public void setChannels(List channels) { + this.channels = channels; + } + + public List getContentTypes() { + return contentTypes; + } + + public void setContentTypes(List contentTypes) { + this.contentTypes = contentTypes; + } + } +} diff --git a/src/main/java/kz/konturai/parser/model/MarketingAnalysis.java b/src/main/java/kz/konturai/parser/model/MarketingAnalysis.java new file mode 100644 index 0000000..8e82928 --- /dev/null +++ b/src/main/java/kz/konturai/parser/model/MarketingAnalysis.java @@ -0,0 +1,157 @@ +package kz.konturai.parser.model; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +import java.time.LocalDateTime; +import java.util.Map; + +@Document(collection = "marketing_analysis") +public class MarketingAnalysis { + + @Id + private String id; + + @Field("product") + private String product; + + @Field("location") + private String location; + + @Field("client_type") + private String clientType; + + @Field("differentiator") + private String differentiator; + + @Field("status") + private String status; // queued, processing, completed, failed + + @Field("created_at") + private LocalDateTime createdAt; + + @Field("completed_at") + private LocalDateTime completedAt; + + @Field("report_data") + private Map reportData; // JSON data with report content + + @Field("pdf_filename") + private String pdfFilename; + + @Field("pdf_file_path") + private String pdfFilePath; + + @Field("user_id") + private String userId; // Optional, for future authentication + + public MarketingAnalysis() { + this.createdAt = LocalDateTime.now(); + this.status = "queued"; + } + + public MarketingAnalysis(String product, String location, String clientType, String differentiator) { + this(); + this.product = product; + this.location = location; + this.clientType = clientType; + this.differentiator = differentiator; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getProduct() { + return product; + } + + public void setProduct(String product) { + this.product = product; + } + + public String getLocation() { + return location; + } + + public void setLocation(String location) { + this.location = location; + } + + public String getClientType() { + return clientType; + } + + public void setClientType(String clientType) { + this.clientType = clientType; + } + + public String getDifferentiator() { + return differentiator; + } + + public void setDifferentiator(String differentiator) { + this.differentiator = differentiator; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getCompletedAt() { + return completedAt; + } + + public void setCompletedAt(LocalDateTime completedAt) { + this.completedAt = completedAt; + } + + public Map getReportData() { + return reportData; + } + + public void setReportData(Map reportData) { + this.reportData = reportData; + } + + public String getPdfFilename() { + return pdfFilename; + } + + public void setPdfFilename(String pdfFilename) { + this.pdfFilename = pdfFilename; + } + + public String getPdfFilePath() { + return pdfFilePath; + } + + public void setPdfFilePath(String pdfFilePath) { + this.pdfFilePath = pdfFilePath; + } + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } +} diff --git a/src/main/java/kz/konturai/parser/repository/MarketingAnalysisRepository.java b/src/main/java/kz/konturai/parser/repository/MarketingAnalysisRepository.java new file mode 100644 index 0000000..a49ed49 --- /dev/null +++ b/src/main/java/kz/konturai/parser/repository/MarketingAnalysisRepository.java @@ -0,0 +1,12 @@ +package kz.konturai.parser.repository; + +import kz.konturai.parser.model.MarketingAnalysis; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface MarketingAnalysisRepository extends MongoRepository { + Optional findById(String id); +} diff --git a/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java b/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java new file mode 100644 index 0000000..e95f944 --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java @@ -0,0 +1,394 @@ +package kz.konturai.parser.service; + +import kz.konturai.parser.dto.MarketingAnalysisRequest; +import kz.konturai.parser.dto.MarketingAnalysisResult; +import kz.konturai.parser.model.MarketingAnalysis; +import kz.konturai.parser.repository.MarketingAnalysisRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.MediaType; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.*; + +@Service +public class MarketingAnalysisService { + + private static final Logger logger = LoggerFactory.getLogger(MarketingAnalysisService.class); + + private final MarketingAnalysisRepository repository; + private final OpenAIAnalyticsService openAIAnalyticsService; + private final MinIOService minIOService; + private final ResearchPdfService researchPdfService; + + public MarketingAnalysisService( + MarketingAnalysisRepository repository, + OpenAIAnalyticsService openAIAnalyticsService, + MinIOService minIOService, + ResearchPdfService researchPdfService) { + this.repository = repository; + this.openAIAnalyticsService = openAIAnalyticsService; + this.minIOService = minIOService; + this.researchPdfService = researchPdfService; + } + + public MarketingAnalysis startAnalysis(MarketingAnalysisRequest request) { + MarketingAnalysis analysis = new MarketingAnalysis( + request.getProduct(), + request.getLocation(), + request.getClient(), + request.getDifferentiator()); + analysis.setStatus("queued"); + analysis = repository.save(analysis); + logger.info("Marketing analysis created with ID: {}", analysis.getId()); + return analysis; + } + + @Async("reportGenerationExecutor") + public void processAnalysis(String analysisId, MarketingAnalysisRequest request) { + try { + Optional optAnalysis = repository.findById(analysisId); + if (optAnalysis.isEmpty()) { + logger.error("Marketing analysis not found: {}", analysisId); + return; + } + + MarketingAnalysis analysis = optAnalysis.get(); + analysis.setStatus("processing"); + repository.save(analysis); + + logger.info("Starting marketing analysis processing for ID: {}", analysisId); + + // Generate marketing report + Map reportData = generateMarketingReport(request); + + // Generate markdown content for PDF + String markdownContent = buildMarkdownFromReport(reportData, request); + + // Generate PDF + byte[] pdfBytes = researchPdfService.generatePdfReport( + "Маркетинговый анализ: " + request.getProduct(), + markdownContent, + new ArrayList<>()); + + // Save PDF to MinIO + String filename = "marketing_analysis_" + analysisId + "_" + System.currentTimeMillis() + ".pdf"; + minIOService.uploadFile(filename, pdfBytes, MediaType.APPLICATION_PDF.toString()); + + // Update analysis with results + analysis.setStatus("completed"); + analysis.setCompletedAt(LocalDateTime.now()); + analysis.setReportData(reportData); + analysis.setPdfFilename(filename); + analysis.setPdfFilePath(filename); + repository.save(analysis); + + logger.info("Marketing analysis completed successfully for ID: {}", analysisId); + + } catch (Exception e) { + logger.error("Error processing marketing analysis {}: {}", analysisId, e.getMessage(), e); + try { + Optional optAnalysis = repository.findById(analysisId); + if (optAnalysis.isPresent()) { + MarketingAnalysis analysis = optAnalysis.get(); + analysis.setStatus("failed"); + repository.save(analysis); + } + } catch (Exception saveError) { + logger.error("Failed to update analysis status to failed: {}", saveError.getMessage()); + } + } + } + + private Map generateMarketingReport(MarketingAnalysisRequest request) { + Map report = new HashMap<>(); + + // Build context for AI + String context = String.format( + "Продукт/услуга: %s\n" + + "Локация: %s\n" + + "Тип клиентов: %s\n" + + "Уникальные особенности: %s\n", + request.getProduct(), + request.getLocation(), + request.getClient(), + request.getDifferentiator()); + + // Generate summary + String summaryPrompt = "На основе следующей информации о бизнесе создай краткое резюме маркетингового анализа (2-3 абзаца). " + + + "Выдели ключевые возможности и особенности бизнеса. " + + "Ответ должен быть на русском языке, деловым стилем.\n\n" + context; + String summary = openAIAnalyticsService.generateWithInstruction(context, summaryPrompt, "ru"); + report.put("summary", summary != null ? summary : "Резюме не удалось сгенерировать."); + + // Generate target audience description + String audiencePrompt = "На основе информации о бизнесе опиши целевую аудиторию (1-2 абзаца). " + + "Укажи, какие каналы коммуникации наиболее подходят для этой аудитории. " + + "Ответ должен быть на русском языке.\n\n" + context; + String audienceDescription = openAIAnalyticsService.generateWithInstruction(context, audiencePrompt, "ru"); + + // Extract channels from description or generate separately + String channelsPrompt = "На основе описания бизнеса перечисли 3-5 наиболее подходящих маркетинговых каналов. " + + "Ответ должен быть простым списком через запятую, без нумерации. Пример: Instagram, LinkedIn, Telegram\n\n" + + context; + String channelsStr = openAIAnalyticsService.generateWithInstruction(context, channelsPrompt, "ru"); + List channels = parseChannels(channelsStr); + + Map targetAudience = new HashMap<>(); + targetAudience.put("description", audienceDescription != null ? audienceDescription + : "Описание целевой аудитории не удалось сгенерировать."); + targetAudience.put("channels", + channels.isEmpty() ? Arrays.asList("Instagram", "LinkedIn", "Telegram") : channels); + report.put("targetAudience", targetAudience); + + // Generate recommendations + String recommendationsPrompt = "На основе информации о бизнесе сформулируй 4-6 практических рекомендаций для маркетинговой стратегии. " + + + "Каждая рекомендация должна быть конкретной и применимой. " + + "Ответ должен быть списком рекомендаций, каждая с новой строки, без нумерации.\n\n" + context; + String recommendationsStr = openAIAnalyticsService.generateWithInstruction(context, recommendationsPrompt, + "ru"); + List recommendations = parseRecommendations(recommendationsStr); + report.put("recommendations", + recommendations.isEmpty() ? Arrays.asList("Рекомендации не удалось сгенерировать.") : recommendations); + + // Generate strategy + String strategyPrompt = "На основе информации о бизнесе создай краткую маркетинговую стратегию. " + + "Укажи рекомендуемую длительность кампании (например, '2 недели', '1 месяц'), " + + "3-5 каналов коммуникации и типы контента (например, 'посты', 'сторис', 'баннеры'). " + + "Ответ должен быть структурированным текстом на русском языке.\n\n" + context; + String strategyText = openAIAnalyticsService.generateWithInstruction(context, strategyPrompt, "ru"); + + Map strategy = parseStrategy(strategyText, channels); + report.put("strategy", strategy); + + return report; + } + + private List parseChannels(String channelsStr) { + if (channelsStr == null || channelsStr.trim().isEmpty()) { + return new ArrayList<>(); + } + List channels = new ArrayList<>(); + String[] parts = channelsStr.split("[,;\\n]"); + for (String part : parts) { + String trimmed = part.trim(); + if (!trimmed.isEmpty() && trimmed.length() > 1) { + channels.add(trimmed); + } + } + return channels; + } + + private List parseRecommendations(String recommendationsStr) { + if (recommendationsStr == null || recommendationsStr.trim().isEmpty()) { + return new ArrayList<>(); + } + List recommendations = new ArrayList<>(); + String[] lines = recommendationsStr.split("\\n"); + for (String line : lines) { + String trimmed = line.trim(); + // Remove numbering and bullets + trimmed = trimmed.replaceAll("^[\\d\\-\\.\\*]+\\s*", ""); + if (!trimmed.isEmpty() && trimmed.length() > 10) { + recommendations.add(trimmed); + } + } + return recommendations; + } + + private Map parseStrategy(String strategyText, List defaultChannels) { + Map strategy = new HashMap<>(); + + // Try to extract duration + String duration = "2 недели"; // default + if (strategyText != null) { + if (strategyText.contains("неделя") || strategyText.contains("недели") || strategyText.contains("недель")) { + // Extract duration pattern + java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("(\\d+)\\s*(неделя|недели|недель)"); + java.util.regex.Matcher matcher = pattern.matcher(strategyText); + if (matcher.find()) { + duration = matcher.group(1) + " " + matcher.group(2); + } + } else if (strategyText.contains("месяц") || strategyText.contains("месяца") + || strategyText.contains("месяцев")) { + java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("(\\d+)\\s*(месяц|месяца|месяцев)"); + java.util.regex.Matcher matcher = pattern.matcher(strategyText); + if (matcher.find()) { + duration = matcher.group(1) + " " + matcher.group(2); + } + } + } + strategy.put("duration", duration); + + // Extract channels from strategy text or use default + List channels = parseChannels(strategyText); + if (channels.isEmpty()) { + channels = defaultChannels.isEmpty() ? Arrays.asList("Instagram", "Telegram", "21MC") : defaultChannels; + } + strategy.put("channels", channels); + + // Extract content types + List contentTypes = new ArrayList<>(); + if (strategyText != null) { + String lowerText = strategyText.toLowerCase(); + if (lowerText.contains("пост") || lowerText.contains("посты")) { + contentTypes.add("посты"); + } + if (lowerText.contains("сторис") || lowerText.contains("stories")) { + contentTypes.add("сторис"); + } + if (lowerText.contains("баннер") || lowerText.contains("banner")) { + contentTypes.add("баннеры"); + } + if (lowerText.contains("видео") || lowerText.contains("video")) { + contentTypes.add("видео"); + } + } + if (contentTypes.isEmpty()) { + contentTypes = Arrays.asList("посты", "сторис", "баннеры"); + } + strategy.put("contentTypes", contentTypes); + + return strategy; + } + + private String buildMarkdownFromReport(Map reportData, MarketingAnalysisRequest request) { + StringBuilder markdown = new StringBuilder(); + + markdown.append("# Маркетинговый анализ\n\n"); + markdown.append("## Информация о бизнесе\n\n"); + markdown.append("- **Продукт/услуга:** ").append(request.getProduct()).append("\n"); + markdown.append("- **Локация:** ").append(request.getLocation()).append("\n"); + markdown.append("- **Тип клиентов:** ").append(request.getClient()).append("\n"); + markdown.append("- **Уникальные особенности:** ").append(request.getDifferentiator()).append("\n\n"); + + markdown.append("## Резюме анализа\n\n"); + markdown.append(reportData.get("summary")).append("\n\n"); + + @SuppressWarnings("unchecked") + Map targetAudience = (Map) reportData.get("targetAudience"); + if (targetAudience != null) { + markdown.append("## Целевая аудитория\n\n"); + markdown.append(targetAudience.get("description")).append("\n\n"); + @SuppressWarnings("unchecked") + List channels = (List) targetAudience.get("channels"); + if (channels != null && !channels.isEmpty()) { + markdown.append("### Рекомендуемые каналы:\n\n"); + for (String channel : channels) { + markdown.append("- ").append(channel).append("\n"); + } + markdown.append("\n"); + } + } + + @SuppressWarnings("unchecked") + List recommendations = (List) reportData.get("recommendations"); + if (recommendations != null && !recommendations.isEmpty()) { + markdown.append("## Рекомендации\n\n"); + for (String rec : recommendations) { + markdown.append("- ").append(rec).append("\n"); + } + markdown.append("\n"); + } + + @SuppressWarnings("unchecked") + Map strategy = (Map) reportData.get("strategy"); + if (strategy != null) { + markdown.append("## Маркетинговая стратегия\n\n"); + markdown.append("### Длительность кампании: ").append(strategy.get("duration")).append("\n\n"); + + @SuppressWarnings("unchecked") + List strategyChannels = (List) strategy.get("channels"); + if (strategyChannels != null && !strategyChannels.isEmpty()) { + markdown.append("### Каналы коммуникации:\n\n"); + for (String channel : strategyChannels) { + markdown.append("- ").append(channel).append("\n"); + } + markdown.append("\n"); + } + + @SuppressWarnings("unchecked") + List contentTypes = (List) strategy.get("contentTypes"); + if (contentTypes != null && !contentTypes.isEmpty()) { + markdown.append("### Типы контента:\n\n"); + for (String type : contentTypes) { + markdown.append("- ").append(type).append("\n"); + } + markdown.append("\n"); + } + } + + return markdown.toString(); + } + + public MarketingAnalysisResult getAnalysisResult(String analysisId) { + Optional optAnalysis = repository.findById(analysisId); + if (optAnalysis.isEmpty()) { + return null; + } + + MarketingAnalysis analysis = optAnalysis.get(); + MarketingAnalysisResult result = new MarketingAnalysisResult(); + result.setAnalysisId(analysis.getId()); + result.setStatus(analysis.getStatus()); + result.setCreatedAt(analysis.getCreatedAt()); + result.setCompletedAt(analysis.getCompletedAt()); + + if (analysis.getReportData() != null && "completed".equals(analysis.getStatus())) { + MarketingAnalysisResult.MarketingReport report = buildReportFromData(analysis.getReportData(), analysis); + result.setReport(report); + } + + return result; + } + + public Optional getAnalysisById(String analysisId) { + return repository.findById(analysisId); + } + + private MarketingAnalysisResult.MarketingReport buildReportFromData(Map reportData, + MarketingAnalysis analysis) { + MarketingAnalysisResult.MarketingReport report = new MarketingAnalysisResult.MarketingReport(); + + report.setSummary((String) reportData.get("summary")); + + @SuppressWarnings("unchecked") + Map targetAudienceData = (Map) reportData.get("targetAudience"); + if (targetAudienceData != null) { + MarketingAnalysisResult.TargetAudience targetAudience = new MarketingAnalysisResult.TargetAudience(); + targetAudience.setDescription((String) targetAudienceData.get("description")); + @SuppressWarnings("unchecked") + List channels = (List) targetAudienceData.get("channels"); + targetAudience.setChannels(channels); + report.setTargetAudience(targetAudience); + } + + @SuppressWarnings("unchecked") + List recommendations = (List) reportData.get("recommendations"); + report.setRecommendations(recommendations); + + @SuppressWarnings("unchecked") + Map strategyData = (Map) reportData.get("strategy"); + if (strategyData != null) { + MarketingAnalysisResult.Strategy strategy = new MarketingAnalysisResult.Strategy(); + strategy.setDuration((String) strategyData.get("duration")); + @SuppressWarnings("unchecked") + List channels = (List) strategyData.get("channels"); + strategy.setChannels(channels); + @SuppressWarnings("unchecked") + List contentTypes = (List) strategyData.get("contentTypes"); + strategy.setContentTypes(contentTypes); + report.setStrategy(strategy); + } + + if (analysis.getPdfFilename() != null) { + report.setPdfUrl("/api/marketing/analysis/" + analysis.getId() + "/download"); + } + + return report; + } +} diff --git a/src/main/java/kz/konturai/parser/validator/ClientTypeValidator.java b/src/main/java/kz/konturai/parser/validator/ClientTypeValidator.java new file mode 100644 index 0000000..20cd013 --- /dev/null +++ b/src/main/java/kz/konturai/parser/validator/ClientTypeValidator.java @@ -0,0 +1,28 @@ +package kz.konturai.parser.validator; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import java.util.Arrays; +import java.util.List; + +public class ClientTypeValidator implements ConstraintValidator { + + private static final List VALID_CLIENT_TYPES = Arrays.asList( + "B2B клиенты", + "B2C клиенты", + "Частные лица", + "Корпорации", + "Малый бизнес"); + + @Override + public void initialize(ValidClientType constraintAnnotation) { + } + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null || value.trim().isEmpty()) { + return true; // @NotBlank will handle null/empty + } + return VALID_CLIENT_TYPES.contains(value); + } +} diff --git a/src/main/java/kz/konturai/parser/validator/ValidClientType.java b/src/main/java/kz/konturai/parser/validator/ValidClientType.java new file mode 100644 index 0000000..7291e34 --- /dev/null +++ b/src/main/java/kz/konturai/parser/validator/ValidClientType.java @@ -0,0 +1,21 @@ +package kz.konturai.parser.validator; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Constraint(validatedBy = ClientTypeValidator.class) +@Target({ ElementType.FIELD, ElementType.PARAMETER }) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidClientType { + String message() default "Поле 'client' должно быть одним из: B2B клиенты, B2C клиенты, Частные лица, Корпорации, Малый бизнес"; + + Class[] groups() default {}; + + Class[] payload() default {}; +}