target fix

This commit is contained in:
arys
2026-05-02 17:38:40 +05:00
parent 6f3421205a
commit 797997d548
18 changed files with 1308 additions and 44 deletions
+18
View File
@@ -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
}
+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(select-object *)"
]
}
}
+184
View File
@@ -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
+6 -6
View File
@@ -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"
}
@@ -28,5 +28,10 @@ public class TargetingAIAnalysisResult {
private String aiRecommendationsRationale;
private List<String> quickWins;
private List<String> warnings;
private String technicalHealthSummary;
private Integer lpMatchScore;
private String policyRiskLevel;
private Integer suggestedAutomationRulesCount;
private Integer matrixCellsCount;
private TargetingRecommendationDto targetingRecommendation;
}
@@ -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;
}
@@ -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;
}
@@ -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<String> rows;
private List<String> cols;
private List<HypothesisCellDto> matrix;
}
@@ -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<String> criticalFixes;
private List<String> trustBoosters;
}
@@ -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;
}
@@ -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<String> moderationTriggers;
}
@@ -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<String> geoExpansion;
}
@@ -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<String> recommendedPlatforms;
private List<String> recommendedAdFormats;
private List<String> recommendedPlacements;
// ── Бюджет ────────────────────────────────────────────────────────────────
private TargetingBudgetDto budgetRecommendation;
// ── Фазы таргетинга ───────────────────────────────────────────────────────
private List<TargetingPhaseDto> phases;
// ── KPI ───────────────────────────────────────────────────────────────────
private String estimatedCtr;
private String estimatedCpl;
private String estimatedReach;
private String estimatedFrequency;
private String estimatedConversions;
// ── Ключевые выводы из опросника ──────────────────────────────────────────
/** Сигналы из опросника, которые повлияли на рекомендацию */
private List<String> questionnaireSignals;
// ── Конкурентный контекст ─────────────────────────────────────────────────
private String competitiveContext;
private String ciiLevel;
private String differentiationAdvice;
// ── Быстрые победы и предупреждения ──────────────────────────────────────
private List<String> quickWins;
private List<String> warnings;
// ── Контент-стратегия ─────────────────────────────────────────────────────
private PixelCheckDto pixelHealth;
private FunnelStrategyDto funnelStrategy;
private HypothesisMatrixDto hypothesisMatrix;
private LandingPageAuditDto lpAudit;
private List<TargetingOrchestrationDto.AutomationRuleDto> suggestedRules;
private ScalingPlanDto scalingPlan;
private RiskAnalysisDto risks;
private List<String> contentMix;
private String postingFrequency;
private List<String> bestPostingTimes;
// ── CTA и воронка ─────────────────────────────────────────────────────────
private String primaryCta;
private String funnelStage;
private String conversionMechanism;
@@ -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<String> 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<String> 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<String> strengths = profile.getStrengths();
if (strengths == null || strengths.isEmpty()) {
return List.<String>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<String> rows = normalizeTextList(matrix.getRows(), fallback.getRows(), 4);
List<String> cols = normalizeTextList(matrix.getCols(), fallback.getCols(), 4);
List<HypothesisCellDto> fallbackCells = fallback.getMatrix();
List<HypothesisCellDto> incoming = matrix.getMatrix() != null ? matrix.getMatrix() : List.of();
List<HypothesisCellDto> 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<TargetingOrchestrationDto.AutomationRuleDto> normalizeRules(
List<TargetingOrchestrationDto.AutomationRuleDto> rules,
MarketingAnalysisV3Request req,
MarketingAnalysisV3Result res,
MarketingStrategy strategy) {
List<TargetingOrchestrationDto.AutomationRuleDto> fallback = buildDefaultAutomationRules(req, res, strategy);
if (rules == null || rules.isEmpty()) {
return fallback;
}
List<TargetingOrchestrationDto.AutomationRuleDto> 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<String> rows = switch (model) {
case AUTHORITY -> List.of("Экспертный разбор проблемы", "Кейсы с цифрами и исходом", "Методология и процесс работы", "Результат и измеримый эффект");
case TRUST -> List.of("Отзывы и подтверждения", "Гарантии и прозрачность", "Реальный процесс без прикрас", "Доказательства качества");
case CONVERSION -> List.of("Дедлайн и ограничение", "Выгода и конкретный оффер", "Прямой призыв к действию", "Сравнение потерь и выгоды");
case ENTRY -> List.of("Простой первый шаг", "Доступность и понятный старт", "Быстрый результат без сложности", "Минимальный барьер входа");
};
List<String> cols = List.of("UGC-style видео", "Карточка с оффером", "Скриншот или proof-format", "До/после или демонстрация");
List<String> competitorStrengths = extractTopCompetitorStrengths(res);
List<HypothesisCellDto> 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<TargetingOrchestrationDto.AutomationRuleDto> 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<String> 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<String> normalizeTextList(List<String> source, List<String> fallback, int minSize) {
LinkedHashSet<String> 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<String> 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) {
@@ -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) {
@@ -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
@@ -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"));
}
}
@@ -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<HypothesisCellDto> 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<TargetingOrchestrationDto.AutomationRuleDto> 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();
}
}