From 797997d5483744edff092dec96106c83ade60dbf Mon Sep 17 00:00:00 2001 From: arys Date: Sat, 2 May 2026 17:38:40 +0500 Subject: [PATCH] target fix --- .qwen/settings.json | 18 + .qwen/settings.json.orig | 7 + MARKETING_STRATEGY_REFACTORING.md | 184 +++++ google-key.json | 12 +- .../parser/dto/TargetingAIAnalysisResult.java | 5 + .../dto/targeting/FunnelStrategyDto.java | 19 + .../dto/targeting/HypothesisCellDto.java | 22 + .../dto/targeting/HypothesisMatrixDto.java | 23 + .../dto/targeting/LandingPageAuditDto.java | 22 + .../parser/dto/targeting/PixelCheckDto.java | 20 + .../parser/dto/targeting/RiskAnalysisDto.java | 21 + .../parser/dto/targeting/ScalingPlanDto.java | 21 + .../targeting/TargetingRecommendationDto.java | 35 +- .../service/AiTargetingSystemService.java | 640 +++++++++++++++++- .../service/TargetingCampaignService.java | 27 + src/main/resources/application.properties | 3 - ...etingSystemServiceManagementLayerTest.java | 169 +++++ ...ingCampaignServiceManagementLayerTest.java | 104 +++ 18 files changed, 1308 insertions(+), 44 deletions(-) create mode 100644 .qwen/settings.json create mode 100644 .qwen/settings.json.orig create mode 100644 MARKETING_STRATEGY_REFACTORING.md create mode 100644 src/main/java/kz/konturai/parser/dto/targeting/FunnelStrategyDto.java create mode 100644 src/main/java/kz/konturai/parser/dto/targeting/HypothesisCellDto.java create mode 100644 src/main/java/kz/konturai/parser/dto/targeting/HypothesisMatrixDto.java create mode 100644 src/main/java/kz/konturai/parser/dto/targeting/LandingPageAuditDto.java create mode 100644 src/main/java/kz/konturai/parser/dto/targeting/PixelCheckDto.java create mode 100644 src/main/java/kz/konturai/parser/dto/targeting/RiskAnalysisDto.java create mode 100644 src/main/java/kz/konturai/parser/dto/targeting/ScalingPlanDto.java create mode 100644 src/test/java/kz/konturai/parser/service/AiTargetingSystemServiceManagementLayerTest.java create mode 100644 src/test/java/kz/konturai/parser/service/TargetingCampaignServiceManagementLayerTest.java diff --git a/.qwen/settings.json b/.qwen/settings.json new file mode 100644 index 0000000..4850326 --- /dev/null +++ b/.qwen/settings.json @@ -0,0 +1,18 @@ +{ + "permissions": { + "allow": [ + "Bash(select-object *)", + "Bash(mvnw.cmd *)", + "Read(c:\\Users\\777\\IdeaProjects/**)", + "Read(c:\\Users\\777\\IdeaProjects\\api-gateway-konturAi/**)", + "Read(c:\\Users\\777\\IdeaProjects\\konturai-front/**)", + "Read(c:\\Users\\777\\IdeaProjects\\api-gateway-konturAi\\src\\main\\java\\konturai\\kz\\api_gateway\\configuration/**)", + "Read(c:\\Users\\777\\IdeaProjects\\marketing/**)", + "Read(c:\\Users\\777\\IdeaProjects\\marketing\\src/**)", + "Read(c:\\Users\\777\\IdeaProjects\\marketing\\src\\service/**)", + "Read(c:\\Users\\777\\IdeaProjects\\marketing\\src\\config/**)", + "Bash(findstr *)" + ] + }, + "$version": 3 +} \ No newline at end of file diff --git a/.qwen/settings.json.orig b/.qwen/settings.json.orig new file mode 100644 index 0000000..b5f6c36 --- /dev/null +++ b/.qwen/settings.json.orig @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(select-object *)" + ] + } +} \ No newline at end of file diff --git a/MARKETING_STRATEGY_REFACTORING.md b/MARKETING_STRATEGY_REFACTORING.md new file mode 100644 index 0000000..aea5f7a --- /dev/null +++ b/MARKETING_STRATEGY_REFACTORING.md @@ -0,0 +1,184 @@ +# Рефакторинг Маркетинговой Стратегии - Changelog + +## Дата: 11 апреля 2026 + +### 📋 Описание изменений + +Полностью переработана логика генерации маркетинговой стратегии для решения проблем: +- Недостаточное количество постов (было 8, стало 16) +- Неравномерное распределение постов (пропуски 2-3 дня) +- Отсутствие связи между постами и неделями +- Непрофессиональное отображение на фронте + +--- + +### ✅ Что изменено + +#### 1. **Увеличено количество постов: 8 → 16** +- **Было:** 8 постов за 4 недели (2 поста/неделю) +- **Стало:** 16 постов за 4 недели (4 поста/неделю) +- **Распределение:** Пн 09:00, Ср 12:00, Пт 18:00, Сб 20:00 + +#### 2. **Добавлено поле `weekNumber`** +- **Model:** `MarketingStrategy.PostCalendarItem.weekNumber` +- **DTO:** `MarketingStrategyResponse.PostCalendarItem.weekNumber` +- **Цель:** Чёткая связь каждого поста с конкретной неделей стратегии + +#### 3. **Нормализация расписания** +- Новый метод: `normalizePostSchedule()` +- Автоматически выравнивает посты по дням недели +- Гарантирует равномерный ритм публикаций +- Сохраняет валидные даты от AI (если в пределах ±2 дней) + +#### 4. **Усиленный промпт для AI** +- Чёткие инструкции по распределению 16 постов +- Примеры правильного JSON с `weekNumber` +- Явные требования: 4 поста/неделю, Пн-Ср-Пт-Сб +- Увеличено количество видео: 2 → 4 (по 1 в неделю) + +#### 5. **Улучшенная валидация** +- Логирование при < 12 постах от AI +- Авто-расчёт дат если AI вернул некорректные +- Нормализация `weekNumber` для всех постов + +--- + +### 📁 Изменённые файлы + +1. **`MarketingStrategy.java`** (Model) + - Добавлено поле `weekNumber` в `PostCalendarItem` + +2. **`MarketingStrategyResponse.java`** (DTO) + - Добавлено поле `weekNumber` в `PostCalendarItem` + - Добавлены геттеры/сеттеры + поля `videoUrl`, `videoFilename` + +3. **`MarketingStrategyV3Service.java`** (Service) + - Усилен `buildUserPrompt()` — детальные инструкции по распределению + - Переписан `populateStrategyEntity()` — обработка `weekNumber` + - Новый метод `normalizePostSchedule()` — выравнивание расписания + - Новый метод `getDayOffset()` — расчёт дней недели + - Обновлён `buildPostCountPlan()` — 16 постов вместо 12 + - Увеличен лимит видео: 2 → 4 + +4. **`getStrategyResult()`** + - Добавлено поле `weekNumber` в ответ API + +--- + +### 🎯 Результат + +#### **До рефакторинга:** +```json +{ + "postCalendar": [ + { "publishDate": "2026-04-10T09:00:00", "theme": "Кейс: ..." }, + { "publishDate": "2026-04-12T18:00:00", "theme": "Процесс: ..." }, + { "publishDate": "2026-04-15T12:00:00", "theme": "Отзыв: ..." } + // ... всего 8 постов, пропуски 2-3 дня + ] +} +``` + +#### **После рефакторинга:** +```json +{ + "postCalendar": [ + { + "postIndex": 0, + "publishDate": "2026-04-13T09:00:00", + "weekNumber": 1, + "platform": "Instagram", + "contentType": "видео", + "theme": "Кейс: Как мы увеличили продажи на 40%", + "publishTime": "09:00" + }, + { + "postIndex": 1, + "publishDate": "2026-04-15T12:00:00", + "weekNumber": 1, + "platform": "Instagram", + "contentType": "фото", + "theme": "Процесс: За кадром нашей работы", + "publishTime": "12:00" + }, + // ... ещё 14 постов, ровно 4 в неделю + ] +} +``` + +--- + +### 📊 Расписание постов (пример) + +| Неделя | День | Время | Тип контента | +|--------|------|-------|--------------| +| 1 | Понедельник (день 1) | 09:00 | 🎥 Видео (Кейс) | +| 1 | Среда (день 3) | 12:00 | 📸 Фото (Процесс) | +| 1 | Пятница (день 5) | 18:00 | 📸 Фото (Отзыв) | +| 1 | Суббота (день 6) | 20:00 | 📸 Фото (Экспертный) | +| 2 | Понедельник (день 8) | 09:00 | 🎥 Видео (Кейс) | +| 2 | Среда (день 10) | 12:00 | 📸 Фото (Процесс) | +| ... | ... | ... | ... | + +--- + +### 🚀 Преимущества + +✅ **Профессиональный вид** — равномерное расписание, как у SMM-агентств +✅ **Предсказуемость** — клиент знает когда будет каждый пост +✅ **Связность** — каждый пост привязан к неделе и теме +✅ **Гибкость** — AI может предлагать даты, но система нормализует +✅ **Масштабируемость** — легко изменить на 3 или 5 постов/неделю + +--- + +### ⚠️ Важно для фронтенда + +Теперь в ответе API появляется новое поле `weekNumber` у каждого поста: + +```typescript +interface PostCalendarItem { + postIndex: number; + publishDate: string; + weekNumber: number; // ← НОВОЕ ПОЛЕ + platform: string; + contentType: string; + theme: string; + postText: string; + hashtags: string[]; + publishTime: string; + imageUrl?: string; + videoUrl?: string; + // ... +} +``` + +Рекомендуется использовать `weekNumber` для группировки постов на фронте! + +--- + +### 🧪 Тестирование + +После деплоя проверьте: +1. Генерацию новой стратегии — должно быть 16 постов +2. Распределение по неделям — по 4 поста в каждой +3. Даты публикаций — Пн-Ср-Пт-Сб +4. Поле `weekNumber` — должно присутствовать у всех постов +5. Видео-посты — ровно 4 (по 1 в неделю) + +--- + +### 📝 Компиляция + +```bash +cd c:\Users\777\IdeaProjects\telecomBackend +mvnw.cmd compile -DskipTests +``` + +✅ **BUILD SUCCESS** — все изменения компилируются без ошибок! + +--- + +**Автор:** AI Assistant +**Дата:** 11.04.2026 +**Версия:** 2.0 diff --git a/google-key.json b/google-key.json index df9ff33..f39b7a8 100644 --- a/google-key.json +++ b/google-key.json @@ -1,13 +1,13 @@ { "type": "service_account", - "project_id": "data-totality-483616-t5", - "private_key_id": "e9d6df08cfaf391f2b45ccf0473d22221a14fe1e", - "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCkA41lMjU7GSLj\nS5CeXJjT3gsKTp2/Y9adieLrjIXA8GY3Dhm+dwj1+hVF1fKB1qYI072LNku9jpuA\nGbKtDAIDXfTr0NKF097z51+X7Sprnq3lamqzAXnH8PEAdv5A97GtNBDBB6E9S40q\n6uEBfIjeaVENcrt8bnS/jgvtQeDFj/0RoFVOUfapCgFW2zkvqU549z0kZumF9zNF\nNXkKGOelmWm1430QyCWhw4P5SIxfCehz0pD17NSMlUZQvbav/ZZ286SnDzloRT+q\njXyRIXnMMuTnKWov7zqKflmfg8omxkwf/HqGIu2c0+hgmcyQhy8oXj/X9ocXLNsA\n+IclAskFAgMBAAECggEAD+ZHl5GhnGbdksg5WZ4OCK05R8NYQnyktPqayg0szYfg\neMzVTQ6lX+cMQeFcKXRhUxtnqq5Vnad3Rpih0Wmya5jOTJVJRe+Z6O4Qk8ifeKBW\nKOvDMogy7IwxvhhK9zRWf+e7X2MPIUMziP9iVu5iJvbHBIqPR0rpPBqX6GT0VmJs\n6CG1C1FtL3QFvxiZSiWwIzGb0ozZKIHBhqUxapQcUlTSqsSNguJEGeHUXkD3Eg1R\n3gQJs4JKCiQnsCeWcpEcoo/GaP+feTjXDJzPlx6EDzpaFAeyUXPWNm8s8VBHj/dM\nvD9FSL2/YiouublF7TW+cvCCfiYALLJx41hOeeEZTwKBgQDhtbdY1FQwh6T8ciF2\nCH+kWfIUIIiNUwlyllc05Vx6nC9oSuChDOWFMqe2TOpcibAvPCWgLN4lHjxsd0zj\nInTomGFg8mPu7l9g0AUCt6R2DoWWnS2omtyRTCIiq0Ms8WHUwil3U57IS7+2MgK7\nlKwBQtrBQoWZPkgwiuxypLf/uwKBgQC6BkP3B2u2LbP5v0VlNYI1Lvr96Bk6sWCh\ny1kOO0DI/6ubC5WFgTDtTBakbZvRdk5X7OmJCaMRtNCgrAK1twq8dLT5EZFNRrQ/\n+Q/mGsMBnZlf15hqUVnjmYuXy+o9hnU1F84TOjz0AYwvZFmoWicYWf6NDoHfnXyw\nMQOlQMHuPwKBgQC4wBFPuJ0hDCbULGePQe2ZDbbFGswrQejdzombVpKYGfgEGiY8\nLV5B5PC6m8LJ9Dour8Dsl01OfK1g61YYJUAkk3ZB8KY0ERIpIJy5WLBZohFe81FI\nTVp15Cec0SqvQvWdPUDL17qEoNxPuifaexs//QUqY9UPVpeohE5+mK2zuQKBgD8S\nGMEMlwxxem/W1eR2u+rkPt0PcF5h/W1d0Jfupwx/AuPSjaNyJfCOt66QKyBdiDpv\nbM61ocBDGekf6NtLcCxtNV6tAiK9MifufeF6eVt236WLJ3YElwF/8cBJtuWdTOfv\nZkNGn5vrWmhHEc7uogqzmIaiwI3ci4YBWEcTKa1pAoGAQAGOKD6/9m15rGoypQK0\ndM7RHoFiPAga0l6RaRmjInhkPItood84nWvSGwmfDMovvLuMXT4/kqhgoAJBCTp4\nZMWlEnk5XgU3XVATV9fWogSEdPiXq+mwikl2A7xF/PsS79ekc6wVAFS6aog08GH+\ndwLlquOtv2Dd9qW6d6Pe+4I=\n-----END PRIVATE KEY-----\n", - "client_email": "vertex-ai-user@data-totality-483616-t5.iam.gserviceaccount.com", - "client_id": "116751741947593459625", + "project_id": "gen-lang-client-0719272404", + "private_key_id": "feb98d8fa2bd9c7139b3c03fac63900877b652f0", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDDB5ZQNAc4C6Eu\nFA1SD1GE4IxIHuKWPE0aUPe8X1YuX1l3S94XiXzwwXHgaFi8BCJJRVBRPtfvCAM+\nQvv03kfs9KEc6UvkUlaPIgF3cjddshMJuj/7W+YpEKjuuWc649l5NO4uPcug+g1S\nZJhd7UGXbx1Uglw3LSsODJ2b4wNIQUuulGym4VCLQKhu41eGElyIVbecRxrDC2N4\nR3ROgFYilAL7QYzVojTEYea3zIBAXncXvgJcFyhSWDuJq5yJbUkbPNbVNhQ7uw7G\nCFufQWMhMBeBrO62kyufQg+xI+xqMvaDOSs+JReni57ZtUqxnpjD7Gh+BxHnduJJ\n8y/osHB5AgMBAAECggEADvuWe99fXZohHgva0R7Npa5lgIXk5ONJjhfBzHYrhgD7\nu8s3iuncUBegYrvJc1pC8wuQ5cj06E9ehmo5doCcGHNbrD+s/aOotDLJ144N/FP5\nm/Hzx+Dxv95giAR0zoSiGKptEN7c9ac4HkgNn4uiUhqub2dbY77D07663fa08hg1\ntGAtqt7lbw8MZbrCrMDdqkM552xmjxn0MnS8Itkiby1MdFi4H+0xQUO7xavsOLZ6\nQXHexNgUZsjD2ZfxFoo/U/VUaWUHn1/acQh0oJyMJD9/ZerR5Cq7XNKJgUrJUn7B\ndGNaXzymOyIWHbaCkF08+KBX2th65AjSEoJgTZiYnQKBgQDqJmzik/ZomqxySUoh\nRt0WNxO6Ed775qgluQOW9TumYDRLUP4cpRqt1vA/voGpyc7C+bnQRnQnJM0gceH0\nW6AmqdmJVLX+1DJnfkVNichI30Iz5rTYzMqneirEi2kuH6Zdh7VI6jqt3fM6QB0b\nHj2ZOXT70pS3eIGMSLjQgiBZ7QKBgQDVOp6LLWSKguqaJeVCBGbkdOGgqWUb2R6/\nk2nRJCuxnK9JJICotfxTQb+MIbFZlvQGiyb6AKUQadOHrt0VdkDeP0VCTeXw88hf\npKn3fepzxUwP8qsdEg9rmI5Y0jL23oJHPT6EHf3jnUQtpR4Wy1u+c46MRiSpCqPS\nyoHIl0qvPQKBgH2vBn9QgNGBRNNLt7jI+t9zzOjxsslv9A3bk6uyEsSpmyhaXywt\nC3cIiokMzzi+FHff5/d9+dcRD/I8btblX7+hKB1pFQ+mAzA+PSDztkg3XqfRTH2a\n36SqFM7PLjQN1RUbRRnQcJLnMimwRG9L+jCHAN4zGJSq4I7Wci2py4e5AoGAbDNa\n0HUwV7knIyGnvGBPdg55j/VlgUQj/kLnp2Ebav1/VTZYs0PlzrnGYW2XeUbIfamP\nFb3OBQ4ucOOi/IkNUbn5gzPUQvjx7Na6CQS0PBsaBd22RwIrIQZA9YF0qyEuKshB\nOPmfJClht2faZOa8n2A0LapKYB0uFID+arObCmECgYEAx2PS/d178Nfi4oG6nYIR\nAHpjC3JPlt62ySSxqfag+DPdTeOyOK8YglsyLWHGU6vI2OhYCEhuU9LMawj4tVui\nS+4D3A1CS69iOMdJVBkG1jlTP7UMZHlh43jMRW+Tcu/h7fHJaSS9YWPwyX0KUAqo\n7dB0niDoH6m8Cvb9YKXCEzs=\n-----END PRIVATE KEY-----\n", + "client_email": "vertex-express@gen-lang-client-0719272404.iam.gserviceaccount.com", + "client_id": "114082700099175512857", "auth_uri": "https://accounts.google.com/o/oauth2/auth", "token_uri": "https://oauth2.googleapis.com/token", "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", - "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/vertex-ai-user%40data-totality-483616-t5.iam.gserviceaccount.com", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/vertex-express%40gen-lang-client-0719272404.iam.gserviceaccount.com", "universe_domain": "googleapis.com" } diff --git a/src/main/java/kz/konturai/parser/dto/TargetingAIAnalysisResult.java b/src/main/java/kz/konturai/parser/dto/TargetingAIAnalysisResult.java index bcc7c4a..5ede472 100644 --- a/src/main/java/kz/konturai/parser/dto/TargetingAIAnalysisResult.java +++ b/src/main/java/kz/konturai/parser/dto/TargetingAIAnalysisResult.java @@ -28,5 +28,10 @@ public class TargetingAIAnalysisResult { private String aiRecommendationsRationale; private List quickWins; private List warnings; + private String technicalHealthSummary; + private Integer lpMatchScore; + private String policyRiskLevel; + private Integer suggestedAutomationRulesCount; + private Integer matrixCellsCount; private TargetingRecommendationDto targetingRecommendation; } diff --git a/src/main/java/kz/konturai/parser/dto/targeting/FunnelStrategyDto.java b/src/main/java/kz/konturai/parser/dto/targeting/FunnelStrategyDto.java new file mode 100644 index 0000000..48f4cb3 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/targeting/FunnelStrategyDto.java @@ -0,0 +1,19 @@ +package kz.konturai.parser.dto.targeting; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class FunnelStrategyDto { + + private String tof; + private String mof; + private String bof; +} diff --git a/src/main/java/kz/konturai/parser/dto/targeting/HypothesisCellDto.java b/src/main/java/kz/konturai/parser/dto/targeting/HypothesisCellDto.java new file mode 100644 index 0000000..fb8a6e6 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/targeting/HypothesisCellDto.java @@ -0,0 +1,22 @@ +package kz.konturai.parser.dto.targeting; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class HypothesisCellDto { + + private int hookIndex; + private int visualIndex; + private String specificAdIdea; + private String counterHypothesis; + private String funnelStage; + private String expectedOutcome; +} diff --git a/src/main/java/kz/konturai/parser/dto/targeting/HypothesisMatrixDto.java b/src/main/java/kz/konturai/parser/dto/targeting/HypothesisMatrixDto.java new file mode 100644 index 0000000..3d0a853 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/targeting/HypothesisMatrixDto.java @@ -0,0 +1,23 @@ +package kz.konturai.parser.dto.targeting; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class HypothesisMatrixDto { + + private int rowCount; + private int colCount; + private List rows; + private List cols; + private List matrix; +} diff --git a/src/main/java/kz/konturai/parser/dto/targeting/LandingPageAuditDto.java b/src/main/java/kz/konturai/parser/dto/targeting/LandingPageAuditDto.java new file mode 100644 index 0000000..466367a --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/targeting/LandingPageAuditDto.java @@ -0,0 +1,22 @@ +package kz.konturai.parser.dto.targeting; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class LandingPageAuditDto { + + private int matchScore; + private String executiveSummary; + private List criticalFixes; + private List trustBoosters; +} diff --git a/src/main/java/kz/konturai/parser/dto/targeting/PixelCheckDto.java b/src/main/java/kz/konturai/parser/dto/targeting/PixelCheckDto.java new file mode 100644 index 0000000..e0284f4 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/targeting/PixelCheckDto.java @@ -0,0 +1,20 @@ +package kz.konturai.parser.dto.targeting; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class PixelCheckDto { + + private boolean fbp; + private boolean capi; + private boolean gtm; + private String setupInstructions; +} diff --git a/src/main/java/kz/konturai/parser/dto/targeting/RiskAnalysisDto.java b/src/main/java/kz/konturai/parser/dto/targeting/RiskAnalysisDto.java new file mode 100644 index 0000000..879c85b --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/targeting/RiskAnalysisDto.java @@ -0,0 +1,21 @@ +package kz.konturai.parser.dto.targeting; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class RiskAnalysisDto { + + private String fatigueWarning; + private String policyRiskLevel; + private List moderationTriggers; +} diff --git a/src/main/java/kz/konturai/parser/dto/targeting/ScalingPlanDto.java b/src/main/java/kz/konturai/parser/dto/targeting/ScalingPlanDto.java new file mode 100644 index 0000000..37cd008 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/targeting/ScalingPlanDto.java @@ -0,0 +1,21 @@ +package kz.konturai.parser.dto.targeting; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class ScalingPlanDto { + + private String horizontalScaling; + private String budgetScaling; + private List geoExpansion; +} diff --git a/src/main/java/kz/konturai/parser/dto/targeting/TargetingRecommendationDto.java b/src/main/java/kz/konturai/parser/dto/targeting/TargetingRecommendationDto.java index a6e3415..5b6407c 100644 --- a/src/main/java/kz/konturai/parser/dto/targeting/TargetingRecommendationDto.java +++ b/src/main/java/kz/konturai/parser/dto/targeting/TargetingRecommendationDto.java @@ -15,69 +15,50 @@ import java.util.List; @JsonIgnoreProperties(ignoreUnknown = true) public class TargetingRecommendationDto { - // ── Тип таргетинга ──────────────────────────────────────────────────────── - /** - * Рекомендуемый тип: COLD_INTEREST / BEHAVIORAL / LOOKALIKE / - * RETARGETING / LEAD_GEN / AWARENESS / MIXED - */ private String recommendedType; - - /** Читаемое название типа */ private String recommendedTypeLabel; - - /** Почему AI выбрал именно этот тип — на основе данных опросника */ private String typeRationale; - // ── Цель кампании ───────────────────────────────────────────────────────── - /** AWARENESS / TRAFFIC / LEADS / ENGAGEMENT / SALES */ private String campaignObjective; - - /** Читаемая цель */ private String campaignObjectiveLabel; - - /** Обоснование цели */ private String campaignObjectiveRationale; - // ── Аудитория ───────────────────────────────────────────────────────────── private AudienceProfileDto audienceProfile; - // ── Платформы и форматы ─────────────────────────────────────────────────── private List recommendedPlatforms; private List recommendedAdFormats; private List recommendedPlacements; - // ── Бюджет ──────────────────────────────────────────────────────────────── private TargetingBudgetDto budgetRecommendation; - - // ── Фазы таргетинга ─────────────────────────────────────────────────────── private List phases; - // ── KPI ─────────────────────────────────────────────────────────────────── private String estimatedCtr; private String estimatedCpl; private String estimatedReach; private String estimatedFrequency; private String estimatedConversions; - // ── Ключевые выводы из опросника ────────────────────────────────────────── - /** Сигналы из опросника, которые повлияли на рекомендацию */ private List questionnaireSignals; - // ── Конкурентный контекст ───────────────────────────────────────────────── private String competitiveContext; private String ciiLevel; private String differentiationAdvice; - // ── Быстрые победы и предупреждения ────────────────────────────────────── private List quickWins; private List warnings; - // ── Контент-стратегия ───────────────────────────────────────────────────── + private PixelCheckDto pixelHealth; + private FunnelStrategyDto funnelStrategy; + private HypothesisMatrixDto hypothesisMatrix; + private LandingPageAuditDto lpAudit; + private List suggestedRules; + private ScalingPlanDto scalingPlan; + private RiskAnalysisDto risks; + private List contentMix; private String postingFrequency; private List bestPostingTimes; - // ── CTA и воронка ───────────────────────────────────────────────────────── private String primaryCta; private String funnelStage; private String conversionMechanism; diff --git a/src/main/java/kz/konturai/parser/service/AiTargetingSystemService.java b/src/main/java/kz/konturai/parser/service/AiTargetingSystemService.java index 89fa09e..13cb0d8 100644 --- a/src/main/java/kz/konturai/parser/service/AiTargetingSystemService.java +++ b/src/main/java/kz/konturai/parser/service/AiTargetingSystemService.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import kz.konturai.parser.dto.MarketingAnalysisV3Request; import kz.konturai.parser.dto.MarketingAnalysisV3Result; import kz.konturai.parser.dto.targeting.*; +import kz.konturai.parser.enums.StrategyModel; import kz.konturai.parser.model.MarketingAnalysisV3Document; import kz.konturai.parser.model.MarketingStrategy; import kz.konturai.parser.repository.MarketingAnalysisV3Repository; @@ -15,7 +16,14 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import java.io.InputStream; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.stream.Collectors; /** * AI Targeting System — два независимых режима: @@ -164,23 +172,26 @@ public class AiTargetingSystemService { - Бюджеты указывай в тенге (KZT). """; - String instruction = buildTargetingPromptInstruction(); - String context = buildFullContext(req, res, strategy); + systemPrompt = buildEnhancedTargetingSystemPrompt(); + String instruction = buildEnhancedTargetingPromptInstruction(); + String context = buildEnhancedFullContext(req, res, strategy); String raw = openAIAnalyticsService.generateWithInstructionWithModel( - context, instruction, "ru", "gpt-4o", - systemPrompt, 3000, 150000L); + context, instruction, "ru", strategyModel, + systemPrompt, 4500, 150000L); if (raw == null) { logger.warn("AI returned null for targeting recommendation, using fallback"); - return buildFallbackRecommendation(req, res); + return buildFallbackRecommendation(req, res, strategy); } try { - return objectMapper.readValue(extractJson(raw), TargetingRecommendationDto.class); + TargetingRecommendationDto recommendation = + objectMapper.readValue(extractJson(raw), TargetingRecommendationDto.class); + return validateAndNormalizeRecommendation(recommendation, req, res, strategy); } catch (Exception e) { - logger.error("Failed to parse targeting recommendation. Raw: {}", raw, e); - return buildFallbackRecommendation(req, res); + logger.error("Failed to parse targeting recommendation", e); + return buildFallbackRecommendation(req, res, strategy); } } @@ -463,6 +474,619 @@ public class AiTargetingSystemService { return ctx.toString(); } + private String buildEnhancedTargetingSystemPrompt() { + return """ + You are NOT just a generator. You are a Senior Performance Architect with 20 years of experience in paid acquisition, + funnel strategy, landing page diagnostics, Meta Ads scaling, and growth systems. + Your recommendations must be surgical, commercially sharp, aggressive for growth, but cautious about technical health + and burn-out risk. You speak like a real performance lead and use concepts such as LTV, ROAS, CPL, CAC, + Attribution Window, Retargeting windows, budget pacing, creative fatigue, and conversion friction. + + Rules: + - Return valid JSON only. + - Every narrative field inside JSON must be in Russian. + - Keep recommendations practical and compatible with real paid traffic operations. + - Budget values must stay in KZT. + - Do not omit mandatory sections even if data is limited. + """; + } + + private String buildEnhancedTargetingPromptInstruction() { + return """ + На основе всех данных опросника, анализа рынка, текущих digital assets, сильных сторон конкурентов и модели стратегии + верни только валидный JSON без markdown и без пояснений вне JSON. + + Правила: + - Все текстовые поля на русском языке. + - Используй профессиональную performance-терминологию: ROAS, CPL, CTR, LTV, CAC, Pixel, CAPI, GTM, Retargeting. + - Не пропускай ни один обязательный блок. + - hypothesisMatrix обязан содержать ровно 4 rows, 4 cols и 16 cells. + - lpAudit.matchScore должен быть числом от 0 до 100. + - suggestedRules должен содержать 3 реалистичных automation rules. + - Funnel strategy обязана разделять холодную, теплую и горячую аудиторию. + - LP audit должен указывать 3 типичных conversion killers ниши. + - Risk analysis обязан учитывать policy risk и creative fatigue. + + Верни JSON со всеми существующими полями recommendation и обязательными новыми блоками: + pixelHealth, funnelStrategy, hypothesisMatrix, lpAudit, suggestedRules, scalingPlan, risks. + """; + } + + private String buildEnhancedFullContext( + MarketingAnalysisV3Request req, + MarketingAnalysisV3Result res, + MarketingStrategy strategy) { + StringBuilder ctx = new StringBuilder(buildFullContext(req, res, strategy)); + appendSocialLinksContext(ctx, req); + appendCompetitorStrengthsContext(ctx, res); + appendCompetitiveInterpretation(ctx, req, res); + appendStrategyModelContext(ctx, strategy); + return ctx.toString(); + } + + private void appendSocialLinksContext(StringBuilder ctx, MarketingAnalysisV3Request req) { + if (req == null || req.getUserSocialLinks() == null || req.getUserSocialLinks().isEmpty()) { + return; + } + ctx.append("\n=== CURRENT DIGITAL ASSETS ===\n"); + req.getUserSocialLinks().stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(link -> !link.isBlank()) + .forEach(link -> ctx.append("- ").append(link).append("\n")); + } + + private void appendCompetitorStrengthsContext(StringBuilder ctx, MarketingAnalysisV3Result res) { + List strengths = extractTopCompetitorStrengths(res); + if (strengths.isEmpty()) { + return; + } + ctx.append("\n=== TOP COMPETITOR STRENGTHS ===\n"); + strengths.forEach(strength -> ctx.append("- ").append(strength).append("\n")); + } + + private List extractTopCompetitorStrengths(MarketingAnalysisV3Result res) { + if (res == null || res.getCompetitorMap() == null) { + return List.of(); + } + return res.getCompetitorMap().stream() + .filter(Objects::nonNull) + .sorted(Comparator + .comparingInt((MarketingAnalysisV3Result.CompetitorProfile profile) -> + profile.getStrengths() != null ? profile.getStrengths().size() : 0) + .reversed() + .thenComparingInt(MarketingAnalysisV3Result.CompetitorProfile::getFollowers).reversed()) + .limit(3) + .flatMap(profile -> { + List strengths = profile.getStrengths(); + if (strengths == null || strengths.isEmpty()) { + return List.of().stream(); + } + return strengths.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isBlank()) + .map(s -> safeStr(profile.getName()) + ": " + s); + }) + .distinct() + .collect(Collectors.toList()); + } + + private void appendCompetitiveInterpretation( + StringBuilder ctx, + MarketingAnalysisV3Request req, + MarketingAnalysisV3Result res) { + if (res == null || res.getCompetitionIntensity() == null) { + return; + } + int cii = res.getCompetitionIntensity().getCiiIndex(); + ctx.append("\n=== PERFORMANCE INTERPRETATION ===\n"); + if (cii >= 70) { + ctx.append("- Нужна агрессивная differentiation strategy и жесткий контроль creative fatigue.\n"); + } else if (cii >= 40) { + ctx.append("- Нужен баланс между дифференциацией и cost-efficient закупкой.\n"); + } else { + ctx.append("- Можно активнее тестировать холодный трафик и более широкий охват.\n"); + } + if (req != null && req.getAverageCheck() != null && "HIGH".equalsIgnoreCase(req.getAverageCheck().name())) { + ctx.append("- Высокий чек: усиливаем trust layer, BOF и качественную аргументацию оффера.\n"); + } + if (res.getContentProfile() != null) { + ctx.append("- Доля video content у конкурентов: ") + .append(res.getContentProfile().getVideoShare()) + .append("%.\n"); + } + } + + private void appendStrategyModelContext(StringBuilder ctx, MarketingStrategy strategy) { + if (strategy == null) { + return; + } + ctx.append("\n=== STRATEGY MODEL CONTEXT ===\n"); + ctx.append("- scoringModelName: ").append(safeStr(strategy.getScoringModelName())).append("\n"); + ctx.append("- scoringModelTitle: ").append(safeStr(strategy.getScoringModelTitle())).append("\n"); + } + + private TargetingRecommendationDto validateAndNormalizeRecommendation( + TargetingRecommendationDto recommendation, + MarketingAnalysisV3Request req, + MarketingAnalysisV3Result res, + MarketingStrategy strategy) { + TargetingRecommendationDto base = buildFallbackRecommendation(req, res); + TargetingRecommendationDto normalized = recommendation != null ? recommendation : base; + + if (isBlank(normalized.getRecommendedType())) normalized.setRecommendedType(base.getRecommendedType()); + if (isBlank(normalized.getRecommendedTypeLabel())) normalized.setRecommendedTypeLabel(base.getRecommendedTypeLabel()); + if (isBlank(normalized.getTypeRationale())) normalized.setTypeRationale(base.getTypeRationale()); + if (isBlank(normalized.getCampaignObjective())) normalized.setCampaignObjective(base.getCampaignObjective()); + if (isBlank(normalized.getCampaignObjectiveLabel())) normalized.setCampaignObjectiveLabel(base.getCampaignObjectiveLabel()); + if (isBlank(normalized.getCampaignObjectiveRationale())) normalized.setCampaignObjectiveRationale(base.getCampaignObjectiveRationale()); + if (normalized.getAudienceProfile() == null) normalized.setAudienceProfile(base.getAudienceProfile()); + if (normalized.getBudgetRecommendation() == null) normalized.setBudgetRecommendation(base.getBudgetRecommendation()); + if (normalized.getRecommendedPlatforms() == null || normalized.getRecommendedPlatforms().isEmpty()) { + normalized.setRecommendedPlatforms(base.getRecommendedPlatforms()); + } + if (normalized.getRecommendedAdFormats() == null || normalized.getRecommendedAdFormats().isEmpty()) { + normalized.setRecommendedAdFormats(base.getRecommendedAdFormats()); + } + if (normalized.getQuickWins() == null || normalized.getQuickWins().isEmpty()) { + normalized.setQuickWins(base.getQuickWins()); + } + if (normalized.getWarnings() == null || normalized.getWarnings().isEmpty()) { + normalized.setWarnings(base.getWarnings()); + } + + normalized.setQuestionnaireSignals(normalizeTextList( + normalized.getQuestionnaireSignals(), + List.of( + "Покупатель принимает решение не мгновенно и требует прогрева.", + "Сегменту важна понятная ценность оффера до клика.", + "Конкурентная среда влияет на стоимость лида и частоту тестов.", + "Таргетинг должен опираться на реальный customer behavior, а не на абстрактные интересы." + ), + 4)); + + normalized.setRecommendedPlacements(normalizeTextList( + normalized.getRecommendedPlacements(), + List.of("Instagram Feed", "Instagram Stories", "Facebook Feed"), + 3)); + + if (normalized.getPixelHealth() == null) { + normalized.setPixelHealth(buildDefaultPixelHealth(req, res, strategy)); + } else { + normalized.getPixelHealth().setSetupInstructions(truncateText( + defaultIfBlank(normalized.getPixelHealth().getSetupInstructions(), + buildDefaultPixelHealth(req, res, strategy).getSetupInstructions()), + 600)); + } + + if (normalized.getFunnelStrategy() == null) { + normalized.setFunnelStrategy(buildDefaultFunnelStrategy(req, res, strategy)); + } else { + FunnelStrategyDto fallbackFunnel = buildDefaultFunnelStrategy(req, res, strategy); + normalized.getFunnelStrategy().setTof(truncateText(defaultIfBlank(normalized.getFunnelStrategy().getTof(), fallbackFunnel.getTof()), 450)); + normalized.getFunnelStrategy().setMof(truncateText(defaultIfBlank(normalized.getFunnelStrategy().getMof(), fallbackFunnel.getMof()), 450)); + normalized.getFunnelStrategy().setBof(truncateText(defaultIfBlank(normalized.getFunnelStrategy().getBof(), fallbackFunnel.getBof()), 450)); + } + + normalized.setHypothesisMatrix(normalizeMatrix(normalized.getHypothesisMatrix(), req, res, strategy)); + + if (normalized.getLpAudit() == null) { + normalized.setLpAudit(buildDefaultLandingPageAudit(req, res, strategy)); + } else { + LandingPageAuditDto fallbackAudit = buildDefaultLandingPageAudit(req, res, strategy); + normalized.getLpAudit().setMatchScore(Math.max(0, Math.min(100, normalized.getLpAudit().getMatchScore()))); + normalized.getLpAudit().setExecutiveSummary(truncateText( + defaultIfBlank(normalized.getLpAudit().getExecutiveSummary(), fallbackAudit.getExecutiveSummary()), + 500)); + normalized.getLpAudit().setCriticalFixes(normalizeTextList( + normalized.getLpAudit().getCriticalFixes(), + fallbackAudit.getCriticalFixes(), + 3)); + normalized.getLpAudit().setTrustBoosters(normalizeTextList( + normalized.getLpAudit().getTrustBoosters(), + fallbackAudit.getTrustBoosters(), + 3)); + } + + normalized.setSuggestedRules(normalizeRules(normalized.getSuggestedRules(), req, res, strategy)); + + if (normalized.getScalingPlan() == null) { + normalized.setScalingPlan(buildDefaultScalingPlan(req, res, strategy)); + } else { + ScalingPlanDto fallbackScaling = buildDefaultScalingPlan(req, res, strategy); + normalized.getScalingPlan().setHorizontalScaling(truncateText( + defaultIfBlank(normalized.getScalingPlan().getHorizontalScaling(), fallbackScaling.getHorizontalScaling()), 400)); + normalized.getScalingPlan().setBudgetScaling(truncateText( + defaultIfBlank(normalized.getScalingPlan().getBudgetScaling(), fallbackScaling.getBudgetScaling()), 400)); + normalized.getScalingPlan().setGeoExpansion(normalizeTextList( + normalized.getScalingPlan().getGeoExpansion(), + fallbackScaling.getGeoExpansion(), + 3)); + } + + if (normalized.getRisks() == null) { + normalized.setRisks(buildDefaultRiskAnalysis(req, res, strategy)); + } else { + RiskAnalysisDto fallbackRisk = buildDefaultRiskAnalysis(req, res, strategy); + normalized.getRisks().setFatigueWarning(truncateText( + defaultIfBlank(normalized.getRisks().getFatigueWarning(), fallbackRisk.getFatigueWarning()), 400)); + normalized.getRisks().setPolicyRiskLevel(normalizePolicyRiskLevel( + defaultIfBlank(normalized.getRisks().getPolicyRiskLevel(), fallbackRisk.getPolicyRiskLevel()))); + normalized.getRisks().setModerationTriggers(normalizeTextList( + normalized.getRisks().getModerationTriggers(), + fallbackRisk.getModerationTriggers(), + 3)); + } + + normalized.setCompetitiveContext(truncateText(defaultIfBlank(normalized.getCompetitiveContext(), "Конкурентная среда требует отдельного тестирования офферов, аудиторий и креативных углов."), 400)); + normalized.setCiiLevel(truncateText(defaultIfBlank(normalized.getCiiLevel(), buildDefaultCiiLevel(res)), 200)); + normalized.setDifferentiationAdvice(truncateText(defaultIfBlank(normalized.getDifferentiationAdvice(), "Нужен отдельный message-market fit в креативах и посадочной странице, чтобы не конкурировать только ценой."), 400)); + normalized.setPrimaryCta(truncateText(defaultIfBlank(normalized.getPrimaryCta(), "Записаться на консультацию"), 120)); + normalized.setFunnelStage(truncateText(defaultIfBlank(normalized.getFunnelStage(), "Середина воронки - прогрев и квалификация лидов"), 200)); + normalized.setConversionMechanism(truncateText(defaultIfBlank(normalized.getConversionMechanism(), "Lead Ads -> менеджер -> квалификация -> продажа"), 220)); + normalized.setContentMix(normalizeTextList( + normalized.getContentMix(), + List.of("40% - демонстрационный контент", "30% - социальные доказательства", "20% - экспертный контент", "10% - продажи"), + 4)); + normalized.setBestPostingTimes(normalizeTextList( + normalized.getBestPostingTimes(), + List.of("09:00", "13:00", "19:00 - 21:00"), + 3)); + normalized.setPostingFrequency(defaultIfBlank(normalized.getPostingFrequency(), "1 раз в день в рабочие дни, 2 раза в выходные")); + + return normalized; + } + + private HypothesisMatrixDto normalizeMatrix( + HypothesisMatrixDto matrix, + MarketingAnalysisV3Request req, + MarketingAnalysisV3Result res, + MarketingStrategy strategy) { + HypothesisMatrixDto fallback = buildDefaultStrategicMatrix(req, res, strategy); + if (matrix == null) { + return fallback; + } + + List rows = normalizeTextList(matrix.getRows(), fallback.getRows(), 4); + List cols = normalizeTextList(matrix.getCols(), fallback.getCols(), 4); + List fallbackCells = fallback.getMatrix(); + List incoming = matrix.getMatrix() != null ? matrix.getMatrix() : List.of(); + List normalizedCells = new ArrayList<>(); + + for (int row = 0; row < 4; row++) { + for (int col = 0; col < 4; col++) { + final int currentRow = row; + final int currentCol = col; + HypothesisCellDto source = incoming.stream() + .filter(cell -> cell != null && cell.getHookIndex() == currentRow && cell.getVisualIndex() == currentCol) + .findFirst() + .orElse(fallbackCells.get(row * 4 + col)); + + normalizedCells.add(HypothesisCellDto.builder() + .hookIndex(row) + .visualIndex(col) + .specificAdIdea(truncateText(defaultIfBlank(source.getSpecificAdIdea(), + fallbackCells.get(row * 4 + col).getSpecificAdIdea()), 240)) + .counterHypothesis(truncateText(defaultIfBlank(source.getCounterHypothesis(), + fallbackCells.get(row * 4 + col).getCounterHypothesis()), 220)) + .funnelStage(normalizeFunnelStage(defaultIfBlank(source.getFunnelStage(), + fallbackCells.get(row * 4 + col).getFunnelStage()))) + .expectedOutcome(truncateText(defaultIfBlank(source.getExpectedOutcome(), + fallbackCells.get(row * 4 + col).getExpectedOutcome()), 220)) + .build()); + } + } + + return HypothesisMatrixDto.builder() + .rowCount(4) + .colCount(4) + .rows(rows) + .cols(cols) + .matrix(normalizedCells) + .build(); + } + + private List normalizeRules( + List rules, + MarketingAnalysisV3Request req, + MarketingAnalysisV3Result res, + MarketingStrategy strategy) { + List fallback = buildDefaultAutomationRules(req, res, strategy); + if (rules == null || rules.isEmpty()) { + return fallback; + } + List normalized = new ArrayList<>(); + for (TargetingOrchestrationDto.AutomationRuleDto rule : rules) { + if (rule == null || rule.getConditions() == null || rule.getConditions().isEmpty()) { + continue; + } + normalized.add(TargetingOrchestrationDto.AutomationRuleDto.builder() + .ruleId(defaultIfBlank(rule.getRuleId(), "rule-" + (normalized.size() + 1))) + .entityLevel(defaultIfBlank(rule.getEntityLevel(), "ADSET").toUpperCase(Locale.ROOT)) + .conditionGroup(rule.getConditionGroup() != null ? rule.getConditionGroup() : TargetingOrchestrationDto.ConditionGroup.AND) + .conditions(rule.getConditions().stream() + .filter(Objects::nonNull) + .map(condition -> TargetingOrchestrationDto.ConditionDto.builder() + .metric(defaultIfBlank(condition.getMetric(), "spend").toLowerCase(Locale.ROOT)) + .operator(condition.getOperator() != null ? condition.getOperator() : TargetingOrchestrationDto.Operator.GREATER_THAN) + .value(condition.getValue() != null ? condition.getValue() : BigDecimal.ZERO) + .build()) + .collect(Collectors.toList())) + .action(defaultIfBlank(rule.getAction(), "PAUSE").toUpperCase(Locale.ROOT)) + .actionValue(rule.getActionValue() != null ? rule.getActionValue() : BigDecimal.ZERO) + .build()); + if (normalized.size() == 3) { + break; + } + } + while (normalized.size() < 3) { + normalized.add(fallback.get(normalized.size())); + } + return normalized; + } + + private TargetingRecommendationDto buildFallbackRecommendation( + MarketingAnalysisV3Request req, + MarketingAnalysisV3Result res, + MarketingStrategy strategy) { + TargetingRecommendationDto recommendation = buildFallbackRecommendation(req, res); + return validateAndNormalizeRecommendation(recommendation, req, res, strategy); + } + + private PixelCheckDto buildDefaultPixelHealth( + MarketingAnalysisV3Request req, + MarketingAnalysisV3Result res, + MarketingStrategy strategy) { + boolean capi = req != null && req.getAverageCheck() != null && "HIGH".equalsIgnoreCase(req.getAverageCheck().name()); + boolean gtm = req != null && req.getUserSocialLinks() != null && !req.getUserSocialLinks().isEmpty(); + return PixelCheckDto.builder() + .fbp(true) + .capi(capi) + .gtm(gtm) + .setupInstructions("Настройте Pixel события PageView, ViewContent и Lead, передайте fbp/fbc, подключите GTM для контроля событий и включите CAPI там, где важна стабильная атрибуция.") + .build(); + } + + private FunnelStrategyDto buildDefaultFunnelStrategy( + MarketingAnalysisV3Request req, + MarketingAnalysisV3Result res, + MarketingStrategy strategy) { + String niche = req != null ? safeStr(req.getBusinessNiche()) : "продукта"; + return FunnelStrategyDto.builder() + .tof("На холодную аудиторию показываем понятную ценность " + niche + ", демонстрацию результата и мягкий CTA без давления на покупку.") + .mof("Для Retargeting по 50%+ видео, вовлеченным и посетителям лендинга усиливаем кейсы, FAQ, social proof и снятие возражений.") + .bof("Горячим лидам даем hard-sell оффер: дедлайн, бонус, ограничение по местам и быстрый путь до менеджера или формы.") + .build(); + } + + private HypothesisMatrixDto buildDefaultStrategicMatrix( + MarketingAnalysisV3Request req, + MarketingAnalysisV3Result res, + MarketingStrategy strategy) { + StrategyModel model = resolveStrategyModel(strategy); + List rows = switch (model) { + case AUTHORITY -> List.of("Экспертный разбор проблемы", "Кейсы с цифрами и исходом", "Методология и процесс работы", "Результат и измеримый эффект"); + case TRUST -> List.of("Отзывы и подтверждения", "Гарантии и прозрачность", "Реальный процесс без прикрас", "Доказательства качества"); + case CONVERSION -> List.of("Дедлайн и ограничение", "Выгода и конкретный оффер", "Прямой призыв к действию", "Сравнение потерь и выгоды"); + case ENTRY -> List.of("Простой первый шаг", "Доступность и понятный старт", "Быстрый результат без сложности", "Минимальный барьер входа"); + }; + List cols = List.of("UGC-style видео", "Карточка с оффером", "Скриншот или proof-format", "До/после или демонстрация"); + List competitorStrengths = extractTopCompetitorStrengths(res); + List cells = new ArrayList<>(); + for (int row = 0; row < rows.size(); row++) { + for (int col = 0; col < cols.size(); col++) { + String stage = row < 2 ? "TOF" : row == 2 ? "MOF" : "BOF"; + String counter = competitorStrengths.isEmpty() + ? "Контр-гипотеза: усиливаем дифференциацию оффера и message-market fit." + : "Контр-гипотеза: переиграть сильную сторону конкурента \"" + competitorStrengths.get((row + col) % competitorStrengths.size()) + "\" через более конкретный value proposition."; + cells.add(HypothesisCellDto.builder() + .hookIndex(row) + .visualIndex(col) + .specificAdIdea(rows.get(row) + " + " + cols.get(col) + ": показать реальный сценарий использования, оффер и следующий шаг до заявки.") + .counterHypothesis(counter) + .funnelStage(stage) + .expectedOutcome("Проверить CTR, вовлечение и влияние на CPL на стадии " + stage + ".") + .build()); + } + } + return HypothesisMatrixDto.builder() + .rowCount(4) + .colCount(4) + .rows(rows) + .cols(cols) + .matrix(cells) + .build(); + } + + private LandingPageAuditDto buildDefaultLandingPageAudit( + MarketingAnalysisV3Request req, + MarketingAnalysisV3Result res, + MarketingStrategy strategy) { + String niche = req != null ? safeStr(req.getBusinessNiche()) : "продукта"; + return LandingPageAuditDto.builder() + .matchScore(72) + .executiveSummary("Лендинг для " + niche + " должен быстрее раскрывать оффер, social proof и путь до конверсии. Сейчас критична ясность первого экрана и связка с рекламным сообщением.") + .criticalFixes(List.of( + "Упростить первый экран: один оффер, один CTA, без перегруза текстом.", + "Добавить кейсы, отзывы или доказательства результата выше первого сильного скролла.", + "Сократить путь до заявки: меньше полей, понятная форма или быстрый мессенджер-канал." + )) + .trustBoosters(List.of( + "Показать реальные отзывы, кейсы или цифры результата с конкретикой.", + "Добавить прозрачные условия: сроки, стоимость, что входит в оффер.", + "Подсветить экспертность команды, реальные лица и подтверждения качества." + )) + .build(); + } + + private List buildDefaultAutomationRules( + MarketingAnalysisV3Request req, + MarketingAnalysisV3Result res, + MarketingStrategy strategy) { + return List.of( + TargetingOrchestrationDto.AutomationRuleDto.builder() + .ruleId("rule-kill-zero-leads") + .entityLevel("ADSET") + .conditionGroup(TargetingOrchestrationDto.ConditionGroup.AND) + .conditions(List.of( + buildCondition("spend", TargetingOrchestrationDto.Operator.GREATER_THAN, 6000), + buildCondition("leads", TargetingOrchestrationDto.Operator.EQUAL, 0) + )) + .action("PAUSE") + .actionValue(BigDecimal.ZERO) + .build(), + TargetingOrchestrationDto.AutomationRuleDto.builder() + .ruleId("rule-scale-winning-adset") + .entityLevel("ADSET") + .conditionGroup(TargetingOrchestrationDto.ConditionGroup.AND) + .conditions(List.of( + buildCondition("roas", TargetingOrchestrationDto.Operator.GREATER_THAN, 4), + buildCondition("purchases", TargetingOrchestrationDto.Operator.GREATER_THAN_OR_EQUAL, 3) + )) + .action("INCREASE_BUDGET") + .actionValue(BigDecimal.valueOf(20)) + .build(), + TargetingOrchestrationDto.AutomationRuleDto.builder() + .ruleId("rule-stop-fatigue") + .entityLevel("AD") + .conditionGroup(TargetingOrchestrationDto.ConditionGroup.AND) + .conditions(List.of( + buildCondition("ctr", TargetingOrchestrationDto.Operator.LESS_THAN, 1), + buildCondition("frequency", TargetingOrchestrationDto.Operator.GREATER_THAN, 3) + )) + .action("PAUSE") + .actionValue(BigDecimal.ZERO) + .build() + ); + } + + private ScalingPlanDto buildDefaultScalingPlan( + MarketingAnalysisV3Request req, + MarketingAnalysisV3Result res, + MarketingStrategy strategy) { + List cities = req != null && req.getPromotionCities() != null && !req.getPromotionCities().isEmpty() + ? normalizeTextList(req.getPromotionCities(), List.of("Алматы", "Астана", "Шымкент"), 3) + : List.of("Алматы", "Астана", "Шымкент"); + return ScalingPlanDto.builder() + .horizontalScaling("Масштабируйте через новые lookalike/interest-кластеры, отдельные creative angles и раздельные placements для Reels, Stories и Feed.") + .budgetScaling("Повышайте бюджет ступенчато на 15-20% каждые 48-72 часа только на связках с устойчивым CPL/ROAS.") + .geoExpansion(cities) + .build(); + } + + private RiskAnalysisDto buildDefaultRiskAnalysis( + MarketingAnalysisV3Request req, + MarketingAnalysisV3Result res, + MarketingStrategy strategy) { + int cii = res != null && res.getCompetitionIntensity() != null ? res.getCompetitionIntensity().getCiiIndex() : 50; + String level = cii >= 70 + ? "High - высокий риск модерации и быстрого выгорания креативов." + : cii >= 40 + ? "Medium - умеренный риск, нужен контроль claims и частоты." + : "Low - низкий риск, но формулировки все равно нужно проверять."; + return RiskAnalysisDto.builder() + .fatigueWarning("При текущей конкурентности креативы могут выгорать за 7-14 дней, поэтому нужно заранее готовить новую матрицу углов и визуалов.") + .policyRiskLevel(level) + .moderationTriggers(List.of( + "Категоричные обещания результата без доказательств.", + "Формулировки, давящие на страх, боль или личную уязвимость пользователя.", + "Слишком агрессивные before/after claims, медицинские или финансовые обещания." + )) + .build(); + } + + private TargetingOrchestrationDto.ConditionDto buildCondition( + String metric, + TargetingOrchestrationDto.Operator operator, + double value) { + return TargetingOrchestrationDto.ConditionDto.builder() + .metric(metric) + .operator(operator) + .value(BigDecimal.valueOf(value)) + .build(); + } + + private StrategyModel resolveStrategyModel(MarketingStrategy strategy) { + if (strategy == null || strategy.getScoringModelName() == null || strategy.getScoringModelName().isBlank()) { + return StrategyModel.ENTRY; + } + try { + return StrategyModel.valueOf(strategy.getScoringModelName().trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ex) { + return StrategyModel.ENTRY; + } + } + + private String buildDefaultCiiLevel(MarketingAnalysisV3Result res) { + if (res == null || res.getCompetitionIntensity() == null) { + return "Средний уровень конкуренции - нужен контролируемый тест офферов и аудиторий."; + } + MarketingAnalysisV3Result.CompetitionIntensity ci = res.getCompetitionIntensity(); + return safeStr(ci.getIntensityLabel()) + " (CII=" + ci.getCiiIndex() + "/100)"; + } + + private List normalizeTextList(List source, List fallback, int minSize) { + LinkedHashSet values = new LinkedHashSet<>(); + if (source != null) { + source.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isBlank()) + .map(value -> truncateText(value, 220)) + .forEach(values::add); + } + if (fallback != null) { + fallback.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isBlank()) + .forEach(values::add); + } + List normalized = new ArrayList<>(values); + while (normalized.size() < minSize && fallback != null && !fallback.isEmpty()) { + normalized.add(fallback.get(normalized.size() % fallback.size())); + } + if (normalized.size() > minSize) { + return normalized.subList(0, minSize); + } + return normalized; + } + + private String normalizePolicyRiskLevel(String value) { + String normalized = defaultIfBlank(value, "Medium - умеренный риск модерации."); + String lower = normalized.toLowerCase(Locale.ROOT); + if (lower.contains("high") || lower.contains("выс")) { + return "High - высокий риск модерации и выгорания креативов."; + } + if (lower.contains("low") || lower.contains("низ")) { + return "Low - низкий риск модерации, но формулировки все равно нужно проверять."; + } + return "Medium - умеренный риск модерации, нужен контроль claims и pressure tactics."; + } + + private String normalizeFunnelStage(String value) { + String upper = defaultIfBlank(value, "TOF").toUpperCase(Locale.ROOT); + if (upper.contains("BOF")) return "BOF"; + if (upper.contains("MOF")) return "MOF"; + return "TOF"; + } + + private String defaultIfBlank(String value, String fallback) { + return isBlank(value) ? fallback : value.trim(); + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private String truncateText(String value, int maxLen) { + if (value == null || value.length() <= maxLen) { + return value; + } + return value.substring(0, maxLen - 3).trim() + "..."; + } + // ── Публикация первого поста ────────────────────────────────────────────── private PublishedPostDto publishFirstPost(MarketingStrategy strategy) { diff --git a/src/main/java/kz/konturai/parser/service/TargetingCampaignService.java b/src/main/java/kz/konturai/parser/service/TargetingCampaignService.java index e9e909f..744aa47 100644 --- a/src/main/java/kz/konturai/parser/service/TargetingCampaignService.java +++ b/src/main/java/kz/konturai/parser/service/TargetingCampaignService.java @@ -549,10 +549,37 @@ public class TargetingCampaignService { .aiRecommendationsRationale(recommendation != null ? recommendation.getTypeRationale() : null) .quickWins(recommendation != null ? recommendation.getQuickWins() : List.of()) .warnings(recommendation != null ? recommendation.getWarnings() : List.of()) + .technicalHealthSummary(buildTechnicalHealthSummary(recommendation)) + .lpMatchScore(recommendation != null && recommendation.getLpAudit() != null + ? recommendation.getLpAudit().getMatchScore() : null) + .policyRiskLevel(recommendation != null && recommendation.getRisks() != null + ? recommendation.getRisks().getPolicyRiskLevel() : null) + .suggestedAutomationRulesCount(recommendation != null && recommendation.getSuggestedRules() != null + ? recommendation.getSuggestedRules().size() : 0) + .matrixCellsCount(recommendation != null + && recommendation.getHypothesisMatrix() != null + && recommendation.getHypothesisMatrix().getMatrix() != null + ? recommendation.getHypothesisMatrix().getMatrix().size() : 0) .targetingRecommendation(recommendation) .build(); } + private String buildTechnicalHealthSummary(TargetingRecommendationDto recommendation) { + if (recommendation == null || recommendation.getPixelHealth() == null) { + return null; + } + int enabled = 0; + if (recommendation.getPixelHealth().isFbp()) enabled++; + if (recommendation.getPixelHealth().isCapi()) enabled++; + if (recommendation.getPixelHealth().isGtm()) enabled++; + + return switch (enabled) { + case 3 -> "Strong"; + case 2 -> "Medium"; + default -> "Poor"; + }; + } + private TargetingAudienceProfile buildAudienceProfile( TargetingRecommendationDto recommendation, kz.konturai.parser.dto.AudienceOverrideDto overrideDto) { diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 273138d..9b1ac27 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -109,9 +109,6 @@ deep-research.api.timeout=1800000 security.jwt.secret-base64=ZmFrZV9zZWNyZXRfMTIzNDU2Nzg5MGFiY2RlZmFrZV9zZWNyZXRfMTIzNDU2Nzg5MGFiY2Rl -# Claude (Anthropic) API — для AI предсказаний кампаний -claude.api.key=${CLAUDE_API_KEY:} - encryption.secret-key=ZmFrZV9zZWNyZXRfMTIzNDU2Nzg5MGFiY2RlZmFrZV9zZWNyZXRfMTIzNDU2Nzg5MGFiY2Rl posting.scheduler.enabled=true diff --git a/src/test/java/kz/konturai/parser/service/AiTargetingSystemServiceManagementLayerTest.java b/src/test/java/kz/konturai/parser/service/AiTargetingSystemServiceManagementLayerTest.java new file mode 100644 index 0000000..26e1742 --- /dev/null +++ b/src/test/java/kz/konturai/parser/service/AiTargetingSystemServiceManagementLayerTest.java @@ -0,0 +1,169 @@ +package kz.konturai.parser.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import kz.konturai.parser.dto.MarketingAnalysisV3Request; +import kz.konturai.parser.dto.MarketingAnalysisV3Result; +import kz.konturai.parser.dto.targeting.AudienceProfileDto; +import kz.konturai.parser.dto.targeting.HypothesisCellDto; +import kz.konturai.parser.dto.targeting.HypothesisMatrixDto; +import kz.konturai.parser.dto.targeting.LandingPageAuditDto; +import kz.konturai.parser.dto.targeting.RiskAnalysisDto; +import kz.konturai.parser.dto.targeting.TargetingBudgetDto; +import kz.konturai.parser.dto.targeting.TargetingRecommendationDto; +import kz.konturai.parser.enums.AverageCheck; +import kz.konturai.parser.model.MarketingStrategy; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AiTargetingSystemServiceManagementLayerTest { + + private final AiTargetingSystemService service = + new AiTargetingSystemService(null, new ObjectMapper(), null, null, null, null); + + @Test + void validateAndNormalizeRecommendation_shouldFillManagementBlocksAndClampValues() throws Exception { + MarketingAnalysisV3Request req = new MarketingAnalysisV3Request(); + req.setBusinessNiche("Стоматология"); + req.setPromotionCities(List.of("Алматы")); + req.setAverageCheck(AverageCheck.HIGH); + req.setUserSocialLinks(List.of("https://site.kz", "https://instagram.com/brand")); + + MarketingAnalysisV3Result.CompetitionIntensity intensity = new MarketingAnalysisV3Result.CompetitionIntensity(); + intensity.setCiiIndex(82); + intensity.setIntensityLabel("Высокий"); + + MarketingAnalysisV3Result.CompetitorProfile competitor = new MarketingAnalysisV3Result.CompetitorProfile(); + competitor.setName("Strong Clinic"); + competitor.setFollowers(12000); + competitor.setStrengths(List.of("Много видео-отзывов", "Сильный social proof")); + + MarketingAnalysisV3Result res = new MarketingAnalysisV3Result(); + res.setCompetitionIntensity(intensity); + res.setCompetitorMap(List.of(competitor)); + + MarketingStrategy strategy = new MarketingStrategy(); + strategy.setScoringModelName("TRUST"); + strategy.setScoringModelTitle("Trust"); + strategy.setStatus("completed"); + strategy.setDurationWeeks(4); + + TargetingRecommendationDto partial = TargetingRecommendationDto.builder() + .recommendedType("MIXED") + .recommendedTypeLabel("Комбинированный таргетинг") + .typeRationale("Базовая логика") + .campaignObjective("LEADS") + .campaignObjectiveLabel("Лиды") + .campaignObjectiveRationale("Нужно больше заявок") + .audienceProfile(AudienceProfileDto.builder() + .primarySegment("Жители Алматы") + .ageRange("25-44") + .gender("Женщины 60%, Мужчины 40%") + .build()) + .budgetRecommendation(TargetingBudgetDto.builder() + .dailyBudget("10 000 KZT") + .monthlyBudget("300 000 KZT") + .build()) + .hypothesisMatrix(HypothesisMatrixDto.builder() + .rowCount(1) + .colCount(1) + .rows(List.of("Только один angle")) + .cols(List.of("Только один visual")) + .matrix(List.of(HypothesisCellDto.builder() + .hookIndex(0) + .visualIndex(0) + .specificAdIdea("") + .build())) + .build()) + .lpAudit(LandingPageAuditDto.builder() + .matchScore(140) + .criticalFixes(List.of()) + .trustBoosters(List.of()) + .build()) + .risks(RiskAnalysisDto.builder() + .policyRiskLevel("высокий") + .moderationTriggers(List.of()) + .build()) + .suggestedRules(List.of()) + .build(); + + Method method = AiTargetingSystemService.class.getDeclaredMethod( + "validateAndNormalizeRecommendation", + TargetingRecommendationDto.class, + MarketingAnalysisV3Request.class, + MarketingAnalysisV3Result.class, + MarketingStrategy.class); + method.setAccessible(true); + + TargetingRecommendationDto normalized = (TargetingRecommendationDto) method.invoke( + service, partial, req, res, strategy); + + assertNotNull(normalized.getPixelHealth()); + assertNotNull(normalized.getFunnelStrategy()); + assertNotNull(normalized.getHypothesisMatrix()); + assertNotNull(normalized.getLpAudit()); + assertNotNull(normalized.getScalingPlan()); + assertNotNull(normalized.getRisks()); + assertEquals(4, normalized.getHypothesisMatrix().getRowCount()); + assertEquals(4, normalized.getHypothesisMatrix().getColCount()); + assertEquals(16, normalized.getHypothesisMatrix().getMatrix().size()); + assertEquals(100, normalized.getLpAudit().getMatchScore()); + assertEquals(3, normalized.getLpAudit().getCriticalFixes().size()); + assertEquals(3, normalized.getLpAudit().getTrustBoosters().size()); + assertEquals(3, normalized.getSuggestedRules().size()); + assertEquals(3, normalized.getRisks().getModerationTriggers().size()); + assertTrue(normalized.getRisks().getPolicyRiskLevel().startsWith("High")); + } + + @Test + void buildEnhancedFullContext_shouldIncludeContextEnrichment() throws Exception { + MarketingAnalysisV3Request req = new MarketingAnalysisV3Request(); + req.setBusinessNiche("Онлайн-курсы"); + req.setProductName("Course X"); + req.setGoal("Рост лидов"); + req.setPromotionCities(List.of("Астана")); + req.setUserSocialLinks(List.of("https://site.kz", "https://instagram.com/coursex")); + + MarketingAnalysisV3Result.CompetitionIntensity intensity = new MarketingAnalysisV3Result.CompetitionIntensity(); + intensity.setCiiIndex(68); + intensity.setIntensityLabel("Средний"); + + MarketingAnalysisV3Result.ContentProfile contentProfile = new MarketingAnalysisV3Result.ContentProfile(); + contentProfile.setVideoShare(74.0); + + MarketingAnalysisV3Result.CompetitorProfile competitor = new MarketingAnalysisV3Result.CompetitorProfile(); + competitor.setName("Competitor A"); + competitor.setFollowers(9000); + competitor.setStrengths(List.of("Сильный экспертный контент")); + + MarketingAnalysisV3Result res = new MarketingAnalysisV3Result(); + res.setCompetitionIntensity(intensity); + res.setContentProfile(contentProfile); + res.setCompetitorMap(List.of(competitor)); + + MarketingStrategy strategy = new MarketingStrategy(); + strategy.setScoringModelName("AUTHORITY"); + strategy.setScoringModelTitle("Authority"); + strategy.setStatus("completed"); + strategy.setDurationWeeks(4); + + Method method = AiTargetingSystemService.class.getDeclaredMethod( + "buildEnhancedFullContext", + MarketingAnalysisV3Request.class, + MarketingAnalysisV3Result.class, + MarketingStrategy.class); + method.setAccessible(true); + + String context = (String) method.invoke(service, req, res, strategy); + + assertTrue(context.contains("https://instagram.com/coursex")); + assertTrue(context.contains("Competitor A")); + assertTrue(context.contains("AUTHORITY")); + assertTrue(context.contains("CURRENT DIGITAL ASSETS")); + } +} diff --git a/src/test/java/kz/konturai/parser/service/TargetingCampaignServiceManagementLayerTest.java b/src/test/java/kz/konturai/parser/service/TargetingCampaignServiceManagementLayerTest.java new file mode 100644 index 0000000..8887458 --- /dev/null +++ b/src/test/java/kz/konturai/parser/service/TargetingCampaignServiceManagementLayerTest.java @@ -0,0 +1,104 @@ +package kz.konturai.parser.service; + +import kz.konturai.parser.dto.MarketingAnalysisV3Request; +import kz.konturai.parser.dto.TargetingAIAnalysisResult; +import kz.konturai.parser.dto.targeting.HypothesisCellDto; +import kz.konturai.parser.dto.targeting.HypothesisMatrixDto; +import kz.konturai.parser.dto.targeting.LandingPageAuditDto; +import kz.konturai.parser.dto.targeting.PixelCheckDto; +import kz.konturai.parser.dto.targeting.RiskAnalysisDto; +import kz.konturai.parser.dto.targeting.TargetingOrchestrationDto; +import kz.konturai.parser.dto.targeting.TargetingRecommendationDto; +import kz.konturai.parser.model.MarketingAnalysisV3Document; +import kz.konturai.parser.model.MarketingStrategy; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +class TargetingCampaignServiceManagementLayerTest { + + private final TargetingCampaignService service = + new TargetingCampaignService(null, null, null, null, null, null, null, null, null); + + @Test + void buildAiAnalysisResult_shouldPopulateSummaryFields() throws Exception { + MarketingStrategy strategy = new MarketingStrategy(); + strategy.setId("strategy-1"); + strategy.setAnalysisId("analysis-1"); + + MarketingAnalysisV3Request request = new MarketingAnalysisV3Request(); + request.setBusinessNiche("Автосервис"); + request.setProductName("Oil Change"); + request.setGoal("Лиды"); + + MarketingAnalysisV3Document analysis = new MarketingAnalysisV3Document(); + analysis.setRequestData(request); + + List cells = new ArrayList<>(); + for (int i = 0; i < 16; i++) { + cells.add(HypothesisCellDto.builder() + .hookIndex(i / 4) + .visualIndex(i % 4) + .specificAdIdea("Idea " + i) + .build()); + } + + List rules = List.of( + buildRule("rule-1"), + buildRule("rule-2"), + buildRule("rule-3")); + + TargetingRecommendationDto recommendation = TargetingRecommendationDto.builder() + .recommendedType("MIXED") + .recommendedTypeLabel("Комбинированный") + .campaignObjective("LEADS") + .questionnaireSignals(List.of("signal")) + .typeRationale("reason") + .quickWins(List.of("win")) + .warnings(List.of("warning")) + .pixelHealth(PixelCheckDto.builder().fbp(true).capi(true).gtm(true).build()) + .lpAudit(LandingPageAuditDto.builder().matchScore(88).build()) + .risks(RiskAnalysisDto.builder().policyRiskLevel("High - высокий риск").build()) + .suggestedRules(rules) + .hypothesisMatrix(HypothesisMatrixDto.builder().matrix(cells).build()) + .build(); + + Method method = TargetingCampaignService.class.getDeclaredMethod( + "buildAiAnalysisResult", + MarketingStrategy.class, + MarketingAnalysisV3Document.class, + TargetingRecommendationDto.class); + method.setAccessible(true); + + TargetingAIAnalysisResult result = (TargetingAIAnalysisResult) method.invoke( + service, strategy, analysis, recommendation); + + assertEquals("Strong", result.getTechnicalHealthSummary()); + assertEquals(88, result.getLpMatchScore()); + assertEquals("High - высокий риск", result.getPolicyRiskLevel()); + assertEquals(3, result.getSuggestedAutomationRulesCount()); + assertEquals(16, result.getMatrixCellsCount()); + assertSame(recommendation, result.getTargetingRecommendation()); + } + + private TargetingOrchestrationDto.AutomationRuleDto buildRule(String id) { + return TargetingOrchestrationDto.AutomationRuleDto.builder() + .ruleId(id) + .entityLevel("ADSET") + .conditionGroup(TargetingOrchestrationDto.ConditionGroup.AND) + .conditions(List.of(TargetingOrchestrationDto.ConditionDto.builder() + .metric("spend") + .operator(TargetingOrchestrationDto.Operator.GREATER_THAN) + .value(BigDecimal.TEN) + .build())) + .action("PAUSE") + .actionValue(BigDecimal.ZERO) + .build(); + } +}