diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..d137dd7 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "allow": [ + "Bash(curl -s http://localhost:8080/api/targeting/analysis -X POST -H \"Content-Type: application/json\" -d '{\"topic\":\"test\"}')", + "Bash(curl -s -X POST http://localhost:8080/api/targeting/analysis -H 'Content-Type: application/json' -d '{\"topic\":\"\"}')", + "Bash(curl -s -X POST http://localhost:8080/api/targeting/analysis -H 'Content-Type: application/json' -d '{\"topic\":\"ab\"}')", + "Bash(curl -s -X POST http://localhost:8080/api/targeting/analysis -H 'Content-Type: application/json' -d '{}')", + "Bash(curl -s -X POST http://localhost:8080/api/targeting/strategy -H 'Content-Type: application/json' -d '{\"analysis\": null}')", + "Bash(curl -s -X POST http://localhost:8080/api/targeting/creative -H 'Content-Type: application/json' -d '{\"captionIdea\": \"\"}')" + ] + } +} diff --git a/src/main/java/kz/konturai/parser/dto/TargetingAIAnalysisResult.java b/src/main/java/kz/konturai/parser/dto/TargetingAIAnalysisResult.java index ae10d1f..bcc7c4a 100644 --- a/src/main/java/kz/konturai/parser/dto/TargetingAIAnalysisResult.java +++ b/src/main/java/kz/konturai/parser/dto/TargetingAIAnalysisResult.java @@ -1,5 +1,6 @@ package kz.konturai.parser.dto; +import kz.konturai.parser.dto.targeting.TargetingRecommendationDto; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -12,8 +13,20 @@ import java.util.List; @NoArgsConstructor @AllArgsConstructor public class TargetingAIAnalysisResult { + private String strategyId; + private String analysisId; + private String businessNiche; + private String productName; + private String goal; + private String recommendedTargetingType; + private String recommendedTargetingTypeLabel; + private String recommendedCampaignObjective; + private List questionnaireSignals; private List audienceSegments; private List competitorInsights; private BudgetOptimizationDto budgetOptimization; private String aiRecommendationsRationale; + private List quickWins; + private List warnings; + private TargetingRecommendationDto targetingRecommendation; } diff --git a/src/main/java/kz/konturai/parser/dto/TargetingCampaignRequest.java b/src/main/java/kz/konturai/parser/dto/TargetingCampaignRequest.java index 608e4c1..99dc5e6 100644 --- a/src/main/java/kz/konturai/parser/dto/TargetingCampaignRequest.java +++ b/src/main/java/kz/konturai/parser/dto/TargetingCampaignRequest.java @@ -13,7 +13,6 @@ import java.util.List; @NoArgsConstructor @AllArgsConstructor public class TargetingCampaignRequest { - private String analysisId; private String strategyId; private String campaignName; private String objective; diff --git a/src/main/java/kz/konturai/parser/dto/targeting/AudienceProfileDto.java b/src/main/java/kz/konturai/parser/dto/targeting/AudienceProfileDto.java new file mode 100644 index 0000000..1e05256 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/targeting/AudienceProfileDto.java @@ -0,0 +1,44 @@ +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 AudienceProfileDto { + + /** Основной сегмент — кто это люди */ + private String primarySegment; + + /** Дополнительный сегмент */ + private String secondarySegment; + + /** Возрастной диапазон, например "28-45" */ + private String ageRange; + + /** Пол, например "Женщины 70%, Мужчины 30%" */ + private String gender; + + /** Интересы для таргетинга */ + private List interests; + + /** Поведенческие паттерны для таргетинга */ + private List behaviors; + + /** Города */ + private List geography; + + /** Исключения из аудитории */ + private List exclusions; + + /** Язык аудитории */ + private String language; +} diff --git a/src/main/java/kz/konturai/parser/dto/targeting/PublishedPostDto.java b/src/main/java/kz/konturai/parser/dto/targeting/PublishedPostDto.java new file mode 100644 index 0000000..bf8e578 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/targeting/PublishedPostDto.java @@ -0,0 +1,28 @@ +package kz.konturai.parser.dto.targeting; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PublishedPostDto { + + private int postIndex; + private String platform; + private String contentType; + private String theme; + private String postText; + private List hashtags; + private String imageUrl; + private boolean imagePosted; + private String facebookPostId; + + /** PUBLISHED | TEXT_ONLY | FAILED */ + private String facebookStatus; +} diff --git a/src/main/java/kz/konturai/parser/dto/targeting/TargetingBudgetDto.java b/src/main/java/kz/konturai/parser/dto/targeting/TargetingBudgetDto.java new file mode 100644 index 0000000..ecf62c1 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/targeting/TargetingBudgetDto.java @@ -0,0 +1,36 @@ +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 TargetingBudgetDto { + + /** Рекомендуемый дневной бюджет */ + private String dailyBudget; + + /** Рекомендуемый месячный бюджет */ + private String monthlyBudget; + + /** Стратегия ставок */ + private String bidStrategy; + + /** Обоснование бюджета */ + private String rationale; + + /** Ожидаемая стоимость клика */ + private String estimatedCpc; + + /** Ожидаемая стоимость лида */ + private String estimatedCpl; + + /** Ожидаемый охват в день */ + private String estimatedDailyReach; +} diff --git a/src/main/java/kz/konturai/parser/dto/targeting/TargetingLaunchResultDto.java b/src/main/java/kz/konturai/parser/dto/targeting/TargetingLaunchResultDto.java index 0af095a..78c9abf 100644 --- a/src/main/java/kz/konturai/parser/dto/targeting/TargetingLaunchResultDto.java +++ b/src/main/java/kz/konturai/parser/dto/targeting/TargetingLaunchResultDto.java @@ -5,8 +5,6 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import java.util.List; - @Data @Builder @NoArgsConstructor @@ -18,24 +16,15 @@ public class TargetingLaunchResultDto { private String analysisId; private String scoringModelName; private String scoringModelTitle; + private String businessNiche; + private String goal; - // ── Первый опубликованный пост ──────────────────────────────────────────── - private int postIndex; - private String platform; - private String contentType; // "фото" / "видео" - private String theme; - private String postText; - private List hashtags; - private String imageUrl; - private boolean imagePosted; + // ── AI-рекомендация таргетинга (главный объект) ─────────────────────────── + private TargetingRecommendationDto targetingRecommendation; - // ── Facebook ────────────────────────────────────────────────────────────── - private String facebookPostId; - private String facebookStatus; // "PUBLISHED" | "FAILED" | "TEXT_ONLY" + // ── Опубликованный первый пост ──────────────────────────────────────────── + private PublishedPostDto publishedPost; - // ── Таргетинг (генерируется AI из V3 анализа) ──────────────────────────── - private TargetingSettingsDto targetingSettings; - - // ── Сводка ─────────────────────────────────────────────────────────────── + // ── Итоговое сообщение ──────────────────────────────────────────────────── private String message; } diff --git a/src/main/java/kz/konturai/parser/dto/targeting/TargetingPhaseDto.java b/src/main/java/kz/konturai/parser/dto/targeting/TargetingPhaseDto.java new file mode 100644 index 0000000..d2e1428 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/targeting/TargetingPhaseDto.java @@ -0,0 +1,46 @@ +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 TargetingPhaseDto { + + private int phase; + + /** Название фазы */ + private String name; + + /** Продолжительность */ + private String duration; + + /** Тип таргетинга в этой фазе */ + private String targetingType; + + /** Цель фазы */ + private String objective; + + /** Аудитория для этой фазы */ + private String audience; + + /** Рекламные форматы */ + private List adFormats; + + /** Бюджет на фазу */ + private String budget; + + /** KPI */ + private String kpi; + + /** Тактики */ + private List tactics; +} diff --git a/src/main/java/kz/konturai/parser/dto/targeting/TargetingRecommendationDto.java b/src/main/java/kz/konturai/parser/dto/targeting/TargetingRecommendationDto.java new file mode 100644 index 0000000..8428544 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/targeting/TargetingRecommendationDto.java @@ -0,0 +1,84 @@ +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 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 questionnaireSinals; + + // ── Конкурентный контекст ───────────────────────────────────────────────── + private String competitiveContext; + private String ciiLevel; + private String differentiationAdvice; + + // ── Быстрые победы и предупреждения ────────────────────────────────────── + private List quickWins; + private List warnings; + + // ── Контент-стратегия ───────────────────────────────────────────────────── + 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 359177f..8761d5a 100644 --- a/src/main/java/kz/konturai/parser/service/AiTargetingSystemService.java +++ b/src/main/java/kz/konturai/parser/service/AiTargetingSystemService.java @@ -3,11 +3,7 @@ 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.AdCreativeDto; -import kz.konturai.parser.dto.targeting.MarketingAnalysisDto; -import kz.konturai.parser.dto.targeting.MarketingStrategyDto; -import kz.konturai.parser.dto.targeting.TargetingLaunchResultDto; -import kz.konturai.parser.dto.targeting.TargetingSettingsDto; +import kz.konturai.parser.dto.targeting.*; import kz.konturai.parser.model.MarketingAnalysisV3Document; import kz.konturai.parser.model.MarketingStrategy; import kz.konturai.parser.repository.MarketingAnalysisV3Repository; @@ -19,21 +15,18 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import java.io.InputStream; -import java.util.ArrayList; import java.util.List; -import java.util.stream.Collectors; /** - * AI Targeting System Service. + * AI Targeting System — два независимых режима: * - * Два режима работы: - * 1. STATELESS — generateAnalysis / generateStrategy / generateCreative - * Для React-приложения: принимает тему, возвращает структуры без БД. + * 1. V3-LINKED launchTargetingFromStrategy(strategyId) + * Принимает только strategyId → сам тянет стратегию и анализ из MongoDB → + * строит полноценную AI-рекомендацию таргетинга на основе опросника → + * публикует первый пост в Facebook. * - * 2. V3-LINKED — launchTargetingFromStrategy(strategyId) - * Для полного цикла: берёт готовую V3-стратегию из MongoDB, - * генерирует настройки таргетинга из V3-анализа через AI - * и сразу публикует первый пост в Facebook. + * 2. STATELESS generateAnalysis / generateStrategy / generateCreative + * Для React-виджета: принимает тему, возвращает структуры без БД. */ @Service @RequiredArgsConstructor @@ -41,7 +34,6 @@ public class AiTargetingSystemService { private static final Logger logger = LoggerFactory.getLogger(AiTargetingSystemService.class); - // ── Существующие сервисы ────────────────────────────────────────────────── private final OpenAIAnalyticsService openAIAnalyticsService; private final ObjectMapper objectMapper; private final MarketingStrategyRepository strategyRepository; @@ -49,7 +41,6 @@ public class AiTargetingSystemService { private final FacebookPostingService facebookPostingService; private final MinIOService minIOService; - // ── Конфиг моделей (из application.properties targeting.ai.*) ──────────── @Value("${targeting.ai.audience-model:gpt-4o}") private String analysisModel; @@ -69,22 +60,21 @@ public class AiTargetingSystemService { private int creativeMaxTokens; // ═══════════════════════════════════════════════════════════════════════════ - // V3-LINKED: Запуск таргетинга из существующей V3-стратегии + // V3-LINKED: главный метод — только strategyId // ═══════════════════════════════════════════════════════════════════════════ /** - * Запускает таргетинг на основе готовой V3-стратегии: - * 1. Загружает {@link MarketingStrategy} по strategyId из MongoDB. - * 2. Загружает связанный {@link MarketingAnalysisV3Document}. - * 3. Берёт первый пост из postCalendar. - * 4. Генерирует настройки таргетинга через AI на основе V3-анализа. - * 5. Публикует пост в Facebook (с изображением если есть). + * Единая точка входа для запуска таргетинга. * - * @param strategyId ID стратегии из коллекции marketing_strategy - * @return результат запуска с Facebook postId и настройками таргетинга + * Принимает strategyId → находит стратегию → находит анализ → + * AI строит полную рекомендацию по опроснику → + * публикует первый пост из postCalendar в Facebook. + * + * Пользователь потом вручную вводит бюджет в рекламном кабинете. + * AI рекомендует всё остальное: тип таргетинга, аудиторию, форматы, фазы, KPI. */ public TargetingLaunchResultDto launchTargetingFromStrategy(String strategyId) { - logger.info("Launching targeting from V3 strategy: {}", strategyId); + logger.info("Launching targeting for strategyId={}", strategyId); // 1. Загружаем стратегию MarketingStrategy strategy = strategyRepository.findById(strategyId) @@ -96,474 +86,705 @@ public class AiTargetingSystemService { "Стратегия ещё не готова. Текущий статус: " + strategy.getStatus()); } - List calendar = strategy.getPostCalendar(); - if (calendar == null || calendar.isEmpty()) { - throw new IllegalStateException("Календарь постов пуст для стратегии: " + strategyId); - } - - // 2. Загружаем анализ + // 2. Загружаем анализ — всё необходимое уже в нём MarketingAnalysisV3Document analysis = analysisV3Repository .findById(strategy.getAnalysisId()) .orElseThrow(() -> new IllegalArgumentException( "Анализ не найден: " + strategy.getAnalysisId())); - // 3. Берём первый пост из календаря - MarketingStrategy.PostCalendarItem firstPost = calendar.get(0); - logger.info("First post: platform={}, theme={}", firstPost.getPlatform(), firstPost.getTheme()); + MarketingAnalysisV3Request req = analysis.getRequestData(); + MarketingAnalysisV3Result res = analysis.getResultData(); - // 4. Генерируем настройки таргетинга через AI - TargetingSettingsDto targetingSettings = generateTargetingSettingsFromV3(analysis); + // 3. AI строит рекомендацию таргетинга на основе опросника + результатов анализа + TargetingRecommendationDto recommendation = + buildTargetingRecommendation(req, res, strategy); - // 5. Публикуем в Facebook - String facebookPostId = null; - boolean imagePosted = false; - String facebookStatus; - - try { - byte[] imageBytes = downloadPostImage(firstPost.getImageFilename()); - - if (imageBytes != null && imageBytes.length > 0) { - facebookPostId = facebookPostingService.postToPageWithImage( - null, - firstPost.getPostText(), - firstPost.getHashtags(), - imageBytes); - imagePosted = true; - facebookStatus = "PUBLISHED"; - logger.info("Published to Facebook WITH image. Post ID: {}", facebookPostId); - } else { - facebookPostId = facebookPostingService.postToPage( - null, - firstPost.getPostText(), - firstPost.getHashtags()); - facebookStatus = "TEXT_ONLY"; - logger.info("Published to Facebook TEXT ONLY. Post ID: {}", facebookPostId); - } - } catch (Exception e) { - logger.error("Facebook posting failed for strategy {}: {}", strategyId, e.getMessage(), e); - facebookStatus = "FAILED"; - } + // 4. Публикуем первый пост из postCalendar + PublishedPostDto publishedPost = publishFirstPost(strategy); + // 5. Собираем финальный ответ return TargetingLaunchResultDto.builder() .strategyId(strategyId) .analysisId(strategy.getAnalysisId()) .scoringModelName(strategy.getScoringModelName()) .scoringModelTitle(strategy.getScoringModelTitle()) - .postIndex(0) - .platform(firstPost.getPlatform()) - .contentType(firstPost.getContentType()) - .theme(firstPost.getTheme()) - .postText(firstPost.getPostText()) - .hashtags(firstPost.getHashtags()) - .imageUrl(firstPost.getImageUrl()) - .imagePosted(imagePosted) - .facebookPostId(facebookPostId) - .facebookStatus(facebookStatus) - .targetingSettings(targetingSettings) - .message(buildLaunchMessage(facebookStatus, facebookPostId)) + .businessNiche(safeStr(req != null ? req.getBusinessNiche() : null)) + .goal(safeStr(req != null ? req.getGoal() : null)) + .targetingRecommendation(recommendation) + .publishedPost(publishedPost) + .message(buildSummaryMessage(publishedPost, recommendation)) .build(); } - /** - * Генерирует настройки таргетинга через AI из V3-анализа. - * Использует requestData (ниша, города, поведение) + resultData (CII, конкуренты) - * чтобы сформировать рекомендации по аудитории, бюджету и размещению. - */ - private TargetingSettingsDto generateTargetingSettingsFromV3(MarketingAnalysisV3Document analysis) { - MarketingAnalysisV3Request req = analysis.getRequestData(); - MarketingAnalysisV3Result res = analysis.getResultData(); + public TargetingRecommendationDto generateLinkedRecommendation(String strategyId) { + MarketingStrategy strategy = strategyRepository.findById(strategyId) + .orElseThrow(() -> new IllegalArgumentException("Стратегия не найдена: " + strategyId)); + MarketingAnalysisV3Document analysis = analysisV3Repository.findById(strategy.getAnalysisId()) + .orElseThrow(() -> new IllegalArgumentException("Анализ не найден: " + strategy.getAnalysisId())); + return generateLinkedRecommendation(strategy, analysis); + } + + public TargetingRecommendationDto generateLinkedRecommendation( + MarketingStrategy strategy, + MarketingAnalysisV3Document analysis) { + if (strategy == null) { + throw new IllegalArgumentException("Стратегия не должна быть null"); + } + if (analysis == null) { + throw new IllegalArgumentException("Анализ не должен быть null"); + } + return buildTargetingRecommendation( + analysis.getRequestData(), + analysis.getResultData(), + strategy); + } + + // ── AI: строим рекомендацию таргетинга ─────────────────────────────────── + + private TargetingRecommendationDto buildTargetingRecommendation( + MarketingAnalysisV3Request req, + MarketingAnalysisV3Result res, + MarketingStrategy strategy) { + + logger.info("Building AI targeting recommendation for niche={}", + req != null ? req.getBusinessNiche() : "unknown"); String systemPrompt = """ - Ты — эксперт по платному трафику и таргетированной рекламе на рынке Казахстана. - На основе данных маркетингового анализа сформируй оптимальные настройки таргетинга. + Ты — Senior Performance Marketing Strategist с 15-летним опытом на рынках СНГ. + Специализируешься на платном трафике: Facebook Ads, Instagram Ads, таргетинг. - Возвращай ТОЛЬКО валидный JSON без markdown, без пояснений, без блоков кода. - Чистый JSON-объект и ничего более. Весь текст — на русском языке. + Твоя задача — на основе данных маркетингового опросника и результатов AI-анализа + составить исчерпывающую рекомендацию по таргетингу. + + Правила: + - Возвращай ТОЛЬКО валидный JSON, без markdown, без пояснений вне JSON. + - Все текстовые значения внутри JSON — строго на русском языке. + - Будь конкретным: указывай реальные интересы Facebook/Instagram, поведения, возраст. + - Рекомендуй тип таргетинга исходя из поведения покупателей и стадии бизнеса. + - Бюджеты указывай в тенге (KZT). """; - String instruction = String.format(""" - Сформируй настройки таргетинга для Facebook/Instagram рекламы и верни строго следующий JSON: - { - "audience": "однострочное описание целевой аудитории", - "age": "возрастной диапазон, например 25-44", - "interests": ["интерес1", "интерес2", "интерес3", "интерес4"], - "placement": "Instagram Лента + Facebook Лента", - "budgetPerDay": "рекомендуемый дневной бюджет в KZT", - "estimatedReach": "оценочный охват в день, например 8 000 - 20 000 человек/день" - } + String instruction = buildTargetingPromptInstruction(); + String context = buildFullContext(req, res, strategy); - Данные для анализа: - Ниша: %s - Продукт: %s - Цель: %s - Города: %s - Тип клиентов: %s - Средний чек: %s - Статус SMM: %s - """, - safeGet(req != null ? req.getBusinessNiche() : null, "Не указано"), - safeGet(req != null ? req.getProductName() : null, "Не указано"), - safeGet(req != null ? req.getGoal() : null, "Не указано"), - formatList(req != null ? req.getPromotionCities() : null), - safeGet(req != null && req.getClientTarget() != null - ? req.getClientTarget().name() : null, "Не указано"), - safeGet(req != null && req.getAverageCheck() != null - ? req.getAverageCheck().name() : null, "Не указано"), - safeGet(req != null && req.getSmmStatus() != null - ? req.getSmmStatus().name() : null, "Не указано") - ); + String raw = openAIAnalyticsService.generateWithInstructionWithModel( + context, instruction, "ru", "gpt-4o", + systemPrompt, 3000, 150000L); - // Обогащаем контекст данными из resultData если доступны - String context = buildV3AnalysisContext(res); - - String rawResponse = openAIAnalyticsService.generateWithInstructionWithModel( - context, instruction, "ru", creativeModel, systemPrompt, 800, 60000L); - - if (rawResponse == null) { - logger.warn("AI did not return targeting settings, using defaults"); - return buildDefaultTargetingSettings(req); + if (raw == null) { + logger.warn("AI returned null for targeting recommendation, using fallback"); + return buildFallbackRecommendation(req, res); } try { - String json = extractJson(rawResponse); - return objectMapper.readValue(json, TargetingSettingsDto.class); + return objectMapper.readValue(extractJson(raw), TargetingRecommendationDto.class); } catch (Exception e) { - logger.warn("Failed to parse targeting settings from AI, using defaults. Error: {}", e.getMessage()); - return buildDefaultTargetingSettings(req); + logger.error("Failed to parse targeting recommendation. Raw: {}", raw, e); + return buildFallbackRecommendation(req, res); } } - /** - * Формирует читаемый контекст из resultData V3-анализа для AI-промпта. - */ - private String buildV3AnalysisContext(MarketingAnalysisV3Result res) { - if (res == null) return ""; + private String buildTargetingPromptInstruction() { + return """ + На основе ВСЕХ предоставленных данных опросника и анализа рынка + составь профессиональную рекомендацию по таргетингу и верни строго этот JSON: - StringBuilder ctx = new StringBuilder(); - - if (res.getExecutiveSummary() != null) { - MarketingAnalysisV3Result.ExecutiveSummary es = res.getExecutiveSummary(); - ctx.append("Уровень конкуренции: ").append(safeGet(es.getCompetitionLevel(), "")).append("\n"); - ctx.append("Тренд спроса: ").append(safeGet(es.getDemandTrend(), "")).append("\n"); - } - - if (res.getCompetitionIntensity() != null) { - ctx.append("Индекс конкурентности (CII): ") - .append(res.getCompetitionIntensity().getCiiIndex()).append("/100\n"); - } - - if (res.getContentProfile() != null && res.getContentProfile().getTopHashtags() != null) { - ctx.append("Топ хештеги конкурентов: ") - .append(String.join(", ", res.getContentProfile().getTopHashtags())).append("\n"); - } - - return ctx.toString(); - } - - /** - * Дефолтные настройки таргетинга если AI недоступен. - */ - private TargetingSettingsDto buildDefaultTargetingSettings(MarketingAnalysisV3Request req) { - String city = req != null && req.getPromotionCities() != null && !req.getPromotionCities().isEmpty() - ? req.getPromotionCities().get(0) : "Алматы"; - String niche = req != null ? safeGet(req.getBusinessNiche(), "бизнес") : "бизнес"; - - return TargetingSettingsDto.builder() - .audience("Жители " + city + ", интересующиеся " + niche) - .age("25-44") - .interests(List.of(niche, "Услуги", "Казахстан", "Качество")) - .placement("Instagram Лента + Facebook Лента") - .budgetPerDay("5 000 - 15 000 KZT") - .estimatedReach("5 000 - 20 000 человек/день") - .build(); - } - - /** - * Скачивает изображение из MinIO по имени файла. - * Возвращает null если файл не найден или имя пустое. - */ - private byte[] downloadPostImage(String imageFilename) { - if (imageFilename == null || imageFilename.isBlank()) { - return null; - } - try { - InputStream stream = minIOService.downloadFile(imageFilename); - if (stream == null) return null; - byte[] bytes = stream.readAllBytes(); - stream.close(); - logger.info("Downloaded image from MinIO: {} ({} bytes)", imageFilename, bytes.length); - return bytes; - } catch (Exception e) { - logger.warn("Could not download image '{}' from MinIO: {}", imageFilename, e.getMessage()); - return null; - } - } - - private String buildLaunchMessage(String facebookStatus, String postId) { - return switch (facebookStatus) { - case "PUBLISHED" -> "✅ Пост с изображением успешно опубликован в Facebook. ID: " + postId; - case "TEXT_ONLY" -> "✅ Текстовый пост опубликован в Facebook. ID: " + postId; - case "FAILED" -> "⚠️ Пост не удалось опубликовать в Facebook. Настройки таргетинга сохранены."; - default -> "Запуск таргетинга выполнен."; - }; - } - - // ═══════════════════════════════════════════════════════════════════════════ - // STATELESS: Независимый режим для React-приложения - // ═══════════════════════════════════════════════════════════════════════════ - - public MarketingAnalysisDto generateAnalysis(String topic) { - logger.info("Generating marketing analysis for topic: {}", topic); - - String systemPrompt = """ - Ты — профессиональный маркетинговый аналитик с 15-летним опытом работы - на рынках СНГ (Казахстан, Россия, Узбекистан). Специализируешься на digital-рекламе. - - Возвращай ТОЛЬКО валидный JSON без markdown, без пояснений, без блоков кода. - Чистый JSON-объект и ничего более. Весь текст внутри JSON — на русском языке. - """; - - String instruction = """ - Проанализируй данный бизнес для создания рекламной кампании и верни строго следующий JSON: { - "topic": "string", - "marketOverview": "2-3 предложения о рынке на русском", - "targetAudience": { - "primary": "описание основной аудитории", - "secondary": "описание вторичной аудитории", - "ageRange": "например 25-45", - "gender": "например Женщины 70%, Мужчины 30%", - "income": "например Средний и выше среднего", - "interests": ["интерес1", "интерес2", "интерес3", "интерес4", "интерес5"], - "painPoints": ["боль1", "боль2", "боль3"], - "motivations": ["мотивация1", "мотивация2", "мотивация3"] + "recommendedType": "COLD_INTEREST | BEHAVIORAL | LOOKALIKE | RETARGETING | LEAD_GEN | AWARENESS | MIXED", + "recommendedTypeLabel": "Читаемое название типа таргетинга", + "typeRationale": "2-3 предложения: почему именно этот тип, со ссылкой на данные опросника", + + "campaignObjective": "AWARENESS | TRAFFIC | LEADS | ENGAGEMENT | SALES", + "campaignObjectiveLabel": "Читаемое название цели", + "campaignObjectiveRationale": "Обоснование выбора цели кампании", + + "audienceProfile": { + "primarySegment": "Подробное описание основного сегмента", + "secondarySegment": "Описание дополнительного сегмента", + "ageRange": "например 28-44", + "gender": "например Женщины 65%, Мужчины 35%", + "interests": ["конкретный интерес FB/IG 1", "интерес 2", "интерес 3", "интерес 4", "интерес 5"], + "behaviors": ["поведенческий таргетинг 1", "поведение 2", "поведение 3"], + "geography": ["город 1", "город 2"], + "exclusions": ["исключение 1", "исключение 2"], + "language": "Русский, Казахский" }, - "platforms": [ - { "name": "Instagram", "priority": "Высокий", "reason": "почему на русском" }, - { "name": "Facebook", "priority": "Средний", "reason": "почему" }, - { "name": "TikTok", "priority": "Низкий", "reason": "почему" } - ], - "bestAdFormats": ["Stories", "Reels", "Публикация", "Карусель"], - "postingTimes": ["Пн-Пт 18:00-21:00", "Сб-Вс 10:00-13:00"], + + "recommendedPlatforms": ["Instagram", "Facebook"], + "recommendedAdFormats": ["Stories", "Reels", "Feed"], + "recommendedPlacements": ["Instagram Feed", "Instagram Stories", "Facebook Feed", "Audience Network"], + "budgetRecommendation": { - "minDaily": "5 000 KZT", - "optimalDaily": "15 000 KZT", - "monthly": "300 000 - 450 000 KZT" + "dailyBudget": "например 10 000 – 20 000 KZT", + "monthlyBudget": "например 300 000 – 600 000 KZT", + "bidStrategy": "например Минимальная стоимость результата", + "rationale": "2 предложения почему такой бюджет", + "estimatedCpc": "например 80 – 200 KZT", + "estimatedCpl": "например 1 500 – 4 000 KZT", + "estimatedDailyReach": "например 8 000 – 25 000 человек/день" }, - "keyMessages": ["сообщение1", "сообщение2", "сообщение3"], - "competitorsInsight": "краткий анализ конкурентов на русском", - "recommendedVisuals": "подробное описание лучших визуалов на русском" - } - Бизнес для анализа: - """; - - String rawResponse = openAIAnalyticsService.generateWithInstructionWithModel( - topic, instruction, "ru", analysisModel, systemPrompt, analysisMaxTokens, 120000L); - - return parseResponse(rawResponse, MarketingAnalysisDto.class, "Marketing Analysis"); - } - - public MarketingStrategyDto generateStrategy(MarketingAnalysisDto analysis) { - logger.info("Generating marketing strategy for topic: {}", analysis.getTopic()); - - String systemPrompt = """ - Ты — старший маркетинговый стратег, специалист по performance-маркетингу - на рынках СНГ (Казахстан). Создаёшь эффективные рекламные стратегии. - - Возвращай ТОЛЬКО валидный JSON без markdown, без пояснений, без блоков кода. - Чистый JSON-объект и ничего более. Весь текст внутри JSON — на русском языке. - """; - - String instruction = """ - На основе предоставленного маркетингового анализа создай полную 30-дневную - рекламную стратегию и верни строго следующий JSON: - { - "strategyName": "название стратегии на русском", - "goal": "главная цель кампании на русском", - "duration": "30 дней", "phases": [ { "phase": 1, - "name": "Осведомлённость", + "name": "Холодный охват", "duration": "10 дней", - "objective": "что достигаем", - "tactics": ["тактика1", "тактика2", "тактика3"], - "kpi": "ожидаемый KPI" + "targetingType": "COLD_INTEREST", + "objective": "цель фазы", + "audience": "кто видит рекламу в этой фазе", + "adFormats": ["Stories", "Reels"], + "budget": "30% от общего бюджета", + "kpi": "ожидаемый результат", + "tactics": ["тактика 1", "тактика 2"] }, { "phase": 2, - "name": "Вовлечённость", + "name": "Вовлечение и прогрев", "duration": "10 дней", + "targetingType": "BEHAVIORAL", "objective": "...", - "tactics": ["..."], - "kpi": "..." + "audience": "...", + "adFormats": ["..."], + "budget": "40% от общего бюджета", + "kpi": "...", + "tactics": ["..."] }, { "phase": 3, "name": "Конверсия", "duration": "10 дней", + "targetingType": "RETARGETING", "objective": "...", - "tactics": ["..."], - "kpi": "..." + "audience": "...", + "adFormats": ["..."], + "budget": "30% от общего бюджета", + "kpi": "...", + "tactics": ["..."] } ], - "adCreativesNeeded": [ - { - "id": 1, - "type": "Публикация в ленте", - "theme": "тема поста", - "captionIdea": "идея подписи", - "imageDescription": "детальное описание изображения", - "cta": "Записаться →", - "targetSegment": "сегмент аудитории" - }, - { - "id": 2, - "type": "Сторис", - "theme": "...", - "captionIdea": "...", - "imageDescription": "...", - "cta": "...", - "targetSegment": "..." - }, - { - "id": 3, - "type": "Обложка Reels", - "theme": "...", - "captionIdea": "...", - "imageDescription": "...", - "cta": "...", - "targetSegment": "..." - } + + "estimatedCtr": "например 1.5 – 3%", + "estimatedCpl": "например 1 500 – 4 000 KZT", + "estimatedReach": "например 50 000 – 120 000 человек за 30 дней", + "estimatedFrequency": "например 2 – 3 раза в неделю на человека", + "estimatedConversions": "например 80 – 250 лидов", + + "questionnaireSinals": [ + "Сигнал из опросника 1 — почему он важен для таргетинга", + "Сигнал 2", + "Сигнал 3", + "Сигнал 4" ], - "totalBudget": "450 000 KZT", - "expectedResults": { - "reach": "50 000 - 100 000 человек", - "clicks": "2 000 - 5 000", - "conversions": "100 - 300 лидов", - "cpl": "1 500 - 3 000 KZT" - } + + "competitiveContext": "1-2 предложения о конкурентной среде и как это влияет на таргетинг", + "ciiLevel": "например Высокий (CII=72/100) — нужна дифференциация", + "differentiationAdvice": "Как выделиться в рекламе на фоне конкурентов", + + "quickWins": [ + "Быстрая победа 1 — что можно сделать немедленно", + "Быстрая победа 2", + "Быстрая победа 3" + ], + "warnings": [ + "Предупреждение 1 — чего избегать", + "Предупреждение 2" + ], + + "contentMix": [ + "40% — демонстрационный контент", + "30% — социальные доказательства", + "20% — экспертный контент", + "10% — прямые продажи" + ], + "postingFrequency": "например 1 раз в день в рабочие дни, 2 раза в выходные", + "bestPostingTimes": ["09:00", "13:00", "19:00 – 21:00"], + + "primaryCta": "например «Записаться на бесплатную консультацию»", + "funnelStage": "например Середина воронки — прогрев и доверие", + "conversionMechanism": "например Lead Ads форма → WhatsApp → менеджер" } - Маркетинговый анализ: + Данные для анализа: """; - - String rawResponse = openAIAnalyticsService.generateWithInstructionWithModel( - serializeToJson(analysis, "analysis"), instruction, "ru", - strategyModel, systemPrompt, strategyMaxTokens, 120000L); - - return parseResponse(rawResponse, MarketingStrategyDto.class, "Marketing Strategy"); } - public AdCreativeDto generateCreative( - String captionIdea, - MarketingAnalysisDto analysis, - MarketingStrategyDto strategy) { + /** + * Собирает весь контекст: опросник + результаты анализа + параметры стратегии. + * Это то, что AI получает как "данные для анализа". + */ + private String buildFullContext( + MarketingAnalysisV3Request req, + MarketingAnalysisV3Result res, + MarketingStrategy strategy) { - logger.info("Generating ad creative for idea: {}", captionIdea); + StringBuilder ctx = new StringBuilder(); + + // ── БЛОК 1: Данные опросника ───────────────────────────────────────── + ctx.append("═══ ДАННЫЕ ОПРОСНИКА (источник истины) ═══\n"); + + if (req != null) { + ctx.append("Ниша: ").append(safeStr(req.getBusinessNiche())).append("\n"); + ctx.append("Продукт: ").append(safeStr(req.getProductName())).append("\n"); + ctx.append("Цель бизнеса: ").append(safeStr(req.getGoal())).append("\n"); + ctx.append("Описание продукта: ").append(safeStr(req.getProductDescription())).append("\n\n"); + + ctx.append("── Блок 1: О бизнесе ──\n"); + ctx.append("Стадия бизнеса: ").append(describeEnum(req.getBusinessStage())).append("\n"); + ctx.append("Тип клиентов: ").append(describeEnum(req.getClientTarget())).append("\n"); + ctx.append("Тип оффера: ").append(describeEnum(req.getOfferType())).append("\n"); + ctx.append("Средний чек: ").append(describeEnum(req.getAverageCheck())).append("\n"); + ctx.append("Поведение покупателей: "); + if (req.getCustomerBehaviors() != null) { + req.getCustomerBehaviors().forEach(b -> ctx.append(describeEnum(b)).append("; ")); + } + ctx.append("\n\n"); + + ctx.append("── Блок 2: География ──\n"); + ctx.append("Охват: ").append(describeEnum(req.getGeoScope())).append("\n"); + ctx.append("Основной город: ").append(safeStr(req.getMainCity())).append("\n"); + ctx.append("Города продвижения: ").append(formatList(req.getPromotionCities())).append("\n\n"); + + ctx.append("── Блок 3: Продукт и контент ──\n"); + ctx.append("Частота покупки: ").append(describeEnum(req.getPurchaseFrequency())).append("\n"); + ctx.append("Визуальный фактор: ").append(describeEnum(req.getVisualFactor())).append("\n\n"); + + ctx.append("── Блок 4: Принятие решения ──\n"); + ctx.append("Приоритеты при выборе: "); + if (req.getDecisionPriorities() != null) { + req.getDecisionPriorities().forEach(d -> ctx.append(describeEnum(d)).append("; ")); + } + ctx.append("\n"); + ctx.append("Как находят: "); + if (req.getDiscoveryMethods() != null) { + req.getDiscoveryMethods().forEach(d -> ctx.append(describeEnum(d)).append("; ")); + } + ctx.append("\n"); + ctx.append("Реакция на цену: ").append(describeEnum(req.getPriceFeedback())).append("\n\n"); + + ctx.append("── Блок 5: Текущая ситуация ──\n"); + ctx.append("SMM статус: ").append(describeEnum(req.getSmmStatus())).append("\n"); + ctx.append("Объём лидов сейчас: ").append(describeEnum(req.getLeadVolume())).append("\n"); + ctx.append("Кто обрабатывает: ").append(describeEnum(req.getResponseHandler())).append("\n"); + if (req.getConstraints() != null && !req.getConstraints().isEmpty()) { + ctx.append("Ограничения: "); + req.getConstraints().forEach(c -> ctx.append(describeEnum(c)).append("; ")); + ctx.append("\n"); + } + ctx.append("\n"); + } + + // ── БЛОК 2: Результаты AI-анализа рынка ───────────────────────────── + ctx.append("═══ РЕЗУЛЬТАТЫ AI-АНАЛИЗА РЫНКА ═══\n"); + + if (res != null) { + if (res.getExecutiveSummary() != null) { + MarketingAnalysisV3Result.ExecutiveSummary es = res.getExecutiveSummary(); + ctx.append("Уровень конкуренции: ").append(safeStr(es.getCompetitionLevel())).append("\n"); + ctx.append("Тренд спроса: ").append(safeStr(es.getDemandTrend())).append("\n"); + ctx.append("Активных конкурентов:").append(es.getActiveCompetitors()).append("\n"); + ctx.append("Средний ER ниши: ").append(es.getAverageNicheEr()).append("%\n"); + if (es.getKeyFigures() != null && !es.getKeyFigures().isEmpty()) { + ctx.append("Ключевые цифры: ").append(String.join("; ", es.getKeyFigures())).append("\n"); + } + ctx.append("\n"); + } + + if (res.getCompetitionIntensity() != null) { + MarketingAnalysisV3Result.CompetitionIntensity ci = res.getCompetitionIntensity(); + ctx.append("Индекс CII: ").append(ci.getCiiIndex()).append("/100 — ") + .append(safeStr(ci.getIntensityLabel())).append("\n"); + if (ci.getContributingFactors() != null && !ci.getContributingFactors().isEmpty()) { + ctx.append("Факторы конкуренции: ").append(String.join("; ", ci.getContributingFactors())).append("\n"); + } + ctx.append("\n"); + } + + if (res.getSearchDemand() != null) { + MarketingAnalysisV3Result.SearchDemand sd = res.getSearchDemand(); + ctx.append("Поисковый спрос/мес: ").append(sd.getAvgMonthlySearches()).append(" запросов\n"); + ctx.append("Тренд спроса: ").append(safeStr(sd.getTrendDirection())).append("\n\n"); + } + + if (res.getContentProfile() != null) { + MarketingAnalysisV3Result.ContentProfile cp = res.getContentProfile(); + ctx.append("Контент конкурентов:\n"); + ctx.append(" Демо-контент: ").append(cp.getDemoContentPercent()).append("%\n"); + ctx.append(" Экспертный: ").append(cp.getExpertContentPercent()).append("%\n"); + ctx.append(" Продажный: ").append(cp.getSalesContentPercent()).append("%\n"); + ctx.append(" Отзывы: ").append(cp.getReviewsContentPercent()).append("%\n"); + ctx.append(" Доля видео: ").append(cp.getVideoShare()).append("%\n"); + ctx.append(" Лучшее время: ").append(safeStr(cp.getBestPostingTime())).append("\n"); + if (cp.getTopHashtags() != null && !cp.getTopHashtags().isEmpty()) { + ctx.append(" Топ хештеги: ").append(String.join(", ", cp.getTopHashtags())).append("\n"); + } + ctx.append("\n"); + } + + if (res.getBehavioralPattern() != null) { + MarketingAnalysisV3Result.BehavioralPattern bp = res.getBehavioralPattern(); + ctx.append("Поведенческий паттерн:\n"); + ctx.append(" Используют акции: ").append(bp.getPromoUsagePercent()).append("%\n"); + ctx.append(" Прямая запись: ").append(bp.getDirectBookingPercent()).append("%\n"); + ctx.append(" Цикл принятия: ").append(bp.getAvgCycleDays()).append(" дней\n\n"); + } + + if (res.getReputationAnalysis() != null) { + MarketingAnalysisV3Result.ReputationAnalysis ra = res.getReputationAnalysis(); + ctx.append("Репутация в нише: ").append(ra.getAvgNicheRating()).append(" ★\n"); + if (ra.getTopComplaintThemes() != null && !ra.getTopComplaintThemes().isEmpty()) { + ctx.append("Топ жалобы: ").append(String.join("; ", ra.getTopComplaintThemes())).append("\n"); + } + ctx.append("\n"); + } + + if (res.getStructuredConclusions() != null && !res.getStructuredConclusions().isEmpty()) { + ctx.append("Выводы анализа:\n"); + res.getStructuredConclusions().forEach(c -> ctx.append(" • ").append(c).append("\n")); + ctx.append("\n"); + } + + if (res.getSmmStrategyRationale() != null && !res.getSmmStrategyRationale().isBlank()) { + ctx.append("SMM-ратионале: ").append(res.getSmmStrategyRationale()).append("\n\n"); + } + } + + // ── БЛОК 3: Параметры стратегии ────────────────────────────────────── + ctx.append("═══ ПАРАМЕТРЫ СТРАТЕГИИ ═══\n"); + ctx.append("Модель стратегии: ") + .append(safeStr(strategy.getScoringModelTitle())).append("\n"); + ctx.append("Статус: ").append(safeStr(strategy.getStatus())).append("\n"); + if (strategy.getPriorityPlatforms() != null && !strategy.getPriorityPlatforms().isEmpty()) { + ctx.append("Приоритетные платформы: ") + .append(String.join(", ", strategy.getPriorityPlatforms())).append("\n"); + } + ctx.append("Длительность: ") + .append(safeStr(String.valueOf(strategy.getDurationWeeks()))).append(" недель\n"); + + return ctx.toString(); + } + + // ── Публикация первого поста ────────────────────────────────────────────── + + private PublishedPostDto publishFirstPost(MarketingStrategy strategy) { + List calendar = strategy.getPostCalendar(); + + if (calendar == null || calendar.isEmpty()) { + logger.warn("postCalendar is empty for strategyId={}", strategy.getId()); + return PublishedPostDto.builder() + .facebookStatus("FAILED") + .build(); + } + + MarketingStrategy.PostCalendarItem post = calendar.get(0); + boolean imagePosted = false; + String facebookPostId = null; + String facebookStatus; + + try { + byte[] imageBytes = downloadImage(post.getImageFilename()); + + if (imageBytes != null) { + facebookPostId = facebookPostingService.postToPageWithImage( + null, post.getPostText(), post.getHashtags(), imageBytes); + imagePosted = true; + facebookStatus = "PUBLISHED"; + logger.info("Facebook post published WITH image. postId={}", facebookPostId); + } else { + facebookPostId = facebookPostingService.postToPage( + null, post.getPostText(), post.getHashtags()); + facebookStatus = "TEXT_ONLY"; + logger.info("Facebook post published TEXT ONLY. postId={}", facebookPostId); + } + } catch (Exception e) { + logger.error("Facebook posting failed: {}", e.getMessage(), e); + facebookStatus = "FAILED"; + } + + return PublishedPostDto.builder() + .postIndex(0) + .platform(safeStr(post.getPlatform())) + .contentType(safeStr(post.getContentType())) + .theme(safeStr(post.getTheme())) + .postText(safeStr(post.getPostText())) + .hashtags(post.getHashtags()) + .imageUrl(safeStr(post.getImageUrl())) + .imagePosted(imagePosted) + .facebookPostId(facebookPostId) + .facebookStatus(facebookStatus) + .build(); + } + + // ── Fallback если AI не ответил ─────────────────────────────────────────── + + private TargetingRecommendationDto buildFallbackRecommendation( + MarketingAnalysisV3Request req, MarketingAnalysisV3Result res) { + + String city = req != null && req.getPromotionCities() != null + && !req.getPromotionCities().isEmpty() + ? req.getPromotionCities().get(0) : "Алматы"; + + int cii = res != null && res.getCompetitionIntensity() != null + ? res.getCompetitionIntensity().getCiiIndex() : 50; + + String type = cii > 70 ? "MIXED" : cii > 40 ? "BEHAVIORAL" : "COLD_INTEREST"; + + return TargetingRecommendationDto.builder() + .recommendedType(type) + .recommendedTypeLabel("Комбинированный таргетинг") + .typeRationale("Рекомендация сформирована на основе общих данных ниши.") + .campaignObjective("LEADS") + .campaignObjectiveLabel("Генерация лидов") + .campaignObjectiveRationale("Оптимальная цель для большинства локальных бизнесов.") + .audienceProfile(AudienceProfileDto.builder() + .primarySegment("Жители " + city) + .ageRange("25-44") + .gender("Женщины 60%, Мужчины 40%") + .interests(List.of("местные услуги", "семья", "Казахстан")) + .behaviors(List.of("активные пользователи мобильных устройств")) + .geography(List.of(city)) + .language("Русский, Казахский") + .build()) + .recommendedPlatforms(List.of("Instagram", "Facebook")) + .recommendedAdFormats(List.of("Stories", "Feed", "Reels")) + .budgetRecommendation(TargetingBudgetDto.builder() + .dailyBudget("10 000 – 20 000 KZT") + .monthlyBudget("300 000 – 600 000 KZT") + .bidStrategy("Минимальная стоимость результата") + .estimatedDailyReach("5 000 – 20 000 человек/день") + .build()) + .quickWins(List.of( + "Запустите Stories с CTA первым — низкий CPL", + "Используйте видео: CTR на 30% выше чем фото")) + .warnings(List.of("Убедитесь что менеджер оперативно обрабатывает лиды")) + .primaryCta("Записаться →") + .funnelStage("Верх воронки — охват и интерес") + .build(); + } + + // ── Итоговое сообщение ──────────────────────────────────────────────────── + + private String buildSummaryMessage( + PublishedPostDto post, TargetingRecommendationDto rec) { + String postStatus = switch (post.getFacebookStatus()) { + case "PUBLISHED" -> "✅ Первый пост опубликован в Facebook с изображением (ID: " + post.getFacebookPostId() + ")"; + case "TEXT_ONLY" -> "✅ Первый пост опубликован в Facebook без изображения (ID: " + post.getFacebookPostId() + ")"; + case "FAILED" -> "⚠️ Публикация в Facebook не удалась — проверьте токен"; + default -> "Статус публикации неизвестен"; + }; + return postStatus + ". Тип таргетинга: " + rec.getRecommendedTypeLabel() + + ". Цель: " + rec.getCampaignObjectiveLabel() + "."; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // STATELESS: React-виджет — не требует MongoDB + // ═══════════════════════════════════════════════════════════════════════════ + + public MarketingAnalysisDto generateAnalysis(String topic) { + logger.info("Stateless analysis for topic: {}", topic); String systemPrompt = """ - Ты — профессиональный копирайтер для рекламы в социальных сетях. - Специализируешься на рынке Казахстана, пишешь эмоционально и убедительно. - Твои тексты всегда включают хук, выгоды, социальное доказательство и призыв к действию. - - Возвращай ТОЛЬКО валидный JSON без markdown, без пояснений, без блоков кода. - Чистый JSON-объект и ничего более. Весь текст внутри JSON — на русском языке. + Ты — профессиональный маркетинговый аналитик с 15-летним опытом на рынках СНГ. + Возвращай ТОЛЬКО валидный JSON без markdown. Весь текст на русском языке. """; String instruction = """ - На основе предоставленных данных создай рекламный пост и верни строго следующий JSON: + Проанализируй бизнес и верни строго этот JSON: { - "headline": "короткий цепляющий заголовок до 10 слов на русском", - "caption": "полный текст поста 3-5 предложений с эмоджи на русском", - "hashtags": ["#тег1", "#тег2", "#тег3", "#тег4", "#тег5", "#тег6"], - "ctaButton": "Записаться →", - "targetingSettings": { - "audience": "однострочное описание аудитории на русском", - "age": "возрастной диапазон из анализа", - "interests": ["интерес1", "интерес2", "интерес3", "интерес4"], - "placement": "Instagram Лента + Facebook Лента", - "budgetPerDay": "бюджет из анализа в KZT", - "estimatedReach": "например 5 000 - 15 000 человек/день" - } + "topic": "string", + "marketOverview": "2-3 предложения о рынке", + "targetAudience": { + "primary": "основная аудитория", + "secondary": "вторичная аудитория", + "ageRange": "25-45", + "gender": "Женщины 70%, Мужчины 30%", + "income": "Средний и выше среднего", + "interests": ["интерес1","интерес2","интерес3","интерес4","интерес5"], + "painPoints": ["боль1","боль2","боль3"], + "motivations": ["мотивация1","мотивация2","мотивация3"] + }, + "platforms": [ + {"name":"Instagram","priority":"Высокий","reason":"почему"}, + {"name":"Facebook","priority":"Средний","reason":"почему"}, + {"name":"TikTok","priority":"Низкий","reason":"почему"} + ], + "bestAdFormats": ["Stories","Reels","Публикация","Карусель"], + "postingTimes": ["Пн-Пт 18:00-21:00","Сб-Вс 10:00-13:00"], + "budgetRecommendation": { + "minDaily": "5 000 KZT", + "optimalDaily": "15 000 KZT", + "monthly": "300 000 – 450 000 KZT" + }, + "keyMessages": ["сообщение1","сообщение2","сообщение3"], + "competitorsInsight": "анализ конкурентов", + "recommendedVisuals": "описание лучших визуалов" } - - Данные для создания поста: + Бизнес: """; - String contextJson = buildCreativeContext(captionIdea, analysis, strategy); - String rawResponse = openAIAnalyticsService.generateWithInstructionWithModel( - contextJson, instruction, "ru", creativeModel, systemPrompt, creativeMaxTokens, 90000L); + String raw = openAIAnalyticsService.generateWithInstructionWithModel( + topic, instruction, "ru", analysisModel, systemPrompt, analysisMaxTokens, 120000L); + return parseResponse(raw, MarketingAnalysisDto.class, "Marketing Analysis"); + } - return parseResponse(rawResponse, AdCreativeDto.class, "Ad Creative"); + public MarketingStrategyDto generateStrategy(MarketingAnalysisDto analysis) { + logger.info("Stateless strategy for topic: {}", analysis.getTopic()); + + String systemPrompt = """ + Ты — старший маркетинговый стратег по performance-маркетингу в СНГ. + Возвращай ТОЛЬКО валидный JSON без markdown. Весь текст на русском языке. + """; + + String instruction = """ + Создай 30-дневную стратегию и верни строго этот JSON: + { + "strategyName": "название", + "goal": "главная цель", + "duration": "30 дней", + "phases": [ + {"phase":1,"name":"Осведомлённость","duration":"10 дней", + "objective":"цель","tactics":["т1","т2","т3"],"kpi":"KPI"}, + {"phase":2,"name":"Вовлечённость","duration":"10 дней", + "objective":"цель","tactics":["т1","т2"],"kpi":"KPI"}, + {"phase":3,"name":"Конверсия","duration":"10 дней", + "objective":"цель","tactics":["т1","т2"],"kpi":"KPI"} + ], + "adCreativesNeeded": [ + {"id":1,"type":"Публикация в ленте","theme":"тема", + "captionIdea":"идея","imageDescription":"описание", + "cta":"CTA","targetSegment":"сегмент"}, + {"id":2,"type":"Сторис","theme":"тема", + "captionIdea":"идея","imageDescription":"описание", + "cta":"CTA","targetSegment":"сегмент"}, + {"id":3,"type":"Обложка Reels","theme":"тема", + "captionIdea":"идея","imageDescription":"описание", + "cta":"CTA","targetSegment":"сегмент"} + ], + "totalBudget": "450 000 KZT", + "expectedResults": { + "reach": "50 000 – 100 000 человек", + "clicks": "2 000 – 5 000", + "conversions": "100 – 300 лидов", + "cpl": "1 500 – 3 000 KZT" + } + } + Анализ: + """; + + String raw = openAIAnalyticsService.generateWithInstructionWithModel( + serializeToJson(analysis), instruction, "ru", + strategyModel, systemPrompt, strategyMaxTokens, 120000L); + return parseResponse(raw, MarketingStrategyDto.class, "Marketing Strategy"); + } + + public AdCreativeDto generateCreative( + String captionIdea, MarketingAnalysisDto analysis, MarketingStrategyDto strategy) { + + logger.info("Stateless creative for idea: {}", captionIdea); + + String systemPrompt = """ + Ты — профессиональный копирайтер соцсетей для рынка Казахстана. + Возвращай ТОЛЬКО валидный JSON без markdown. Весь текст на русском. + """; + + String instruction = """ + Создай рекламный пост и верни строго этот JSON: + { + "headline": "заголовок до 10 слов", + "caption": "текст 3-5 предложений с эмодзи", + "hashtags": ["#тег1","#тег2","#тег3","#тег4","#тег5","#тег6"], + "ctaButton": "Записаться →", + "targetingSettings": { + "audience": "описание аудитории", + "age": "возраст", + "interests": ["интерес1","интерес2","интерес3","интерес4"], + "placement": "Instagram Лента + Facebook Лента", + "budgetPerDay": "15 000 KZT", + "estimatedReach": "5 000 – 15 000 человек/день" + } + } + Данные: + """; + + String context; + try { + context = objectMapper.createObjectNode() + .put("captionIdea", captionIdea) + .set("analysis", objectMapper.valueToTree(analysis)) + .toString() + "\nСтратегия: " + serializeToJson(strategy); + } catch (Exception e) { + context = "Идея: " + captionIdea; + } + + String raw = openAIAnalyticsService.generateWithInstructionWithModel( + context, instruction, "ru", creativeModel, systemPrompt, creativeMaxTokens, 90000L); + return parseResponse(raw, AdCreativeDto.class, "Ad Creative"); } // ═══════════════════════════════════════════════════════════════════════════ // HELPERS // ═══════════════════════════════════════════════════════════════════════════ - private String extractJson(String rawResponse) { - if (rawResponse == null || rawResponse.isBlank()) { + private String extractJson(String raw) { + if (raw == null || raw.isBlank()) throw new IllegalStateException("Пустой ответ от AI"); + String t = raw.trim(); + if (t.startsWith("```")) { + int nl = t.indexOf('\n'); + int end = t.lastIndexOf("```"); + if (nl > 0 && end > nl) t = t.substring(nl + 1, end).trim(); } - String trimmed = rawResponse.trim(); - if (trimmed.startsWith("```")) { - int firstNewline = trimmed.indexOf('\n'); - int lastBacktick = trimmed.lastIndexOf("```"); - if (firstNewline > 0 && lastBacktick > firstNewline) { - trimmed = trimmed.substring(firstNewline + 1, lastBacktick).trim(); - } - } - int start = trimmed.indexOf('{'); - int end = trimmed.lastIndexOf('}'); - if (start == -1 || end == -1 || end <= start) { - throw new IllegalStateException("JSON не найден в ответе AI: " - + trimmed.substring(0, Math.min(200, trimmed.length()))); - } - return trimmed.substring(start, end + 1); + int s = t.indexOf('{'), e = t.lastIndexOf('}'); + if (s == -1 || e == -1 || e <= s) + throw new IllegalStateException("JSON не найден: " + t.substring(0, Math.min(200, t.length()))); + return t.substring(s, e + 1); } - private T parseResponse(String rawResponse, Class type, String context) { - if (rawResponse == null) { - throw new IllegalStateException("AI не вернул ответ для: " + context); - } + private T parseResponse(String raw, Class type, String ctx) { + if (raw == null) throw new IllegalStateException("AI не вернул ответ: " + ctx); try { - return objectMapper.readValue(extractJson(rawResponse), type); - } catch (IllegalStateException e) { - throw e; - } catch (Exception e) { - logger.error("Failed to parse {} response. Raw: {}", context, rawResponse, e); - throw new IllegalStateException("Ошибка парсинга " + context + ": " + e.getMessage()); + return objectMapper.readValue(extractJson(raw), type); + } catch (IllegalStateException ex) { + throw ex; + } catch (Exception ex) { + logger.error("Parse error [{}]: {}", ctx, raw, ex); + throw new IllegalStateException("Ошибка парсинга " + ctx + ": " + ex.getMessage()); } } - private String serializeToJson(Object obj, String context) { + private String serializeToJson(Object obj) { + try { return objectMapper.writeValueAsString(obj); } + catch (Exception e) { throw new IllegalStateException("Ошибка сериализации: " + e.getMessage()); } + } + + private byte[] downloadImage(String filename) { + if (filename == null || filename.isBlank()) return null; try { - return objectMapper.writeValueAsString(obj); + InputStream in = minIOService.downloadFile(filename); + if (in == null) return null; + byte[] b = in.readAllBytes(); + in.close(); + return b.length > 0 ? b : null; } catch (Exception e) { - throw new IllegalStateException("Ошибка сериализации " + context + ": " + e.getMessage()); + logger.warn("Cannot download image '{}': {}", filename, e.getMessage()); + return null; } } - private String buildCreativeContext(String captionIdea, MarketingAnalysisDto analysis, MarketingStrategyDto strategy) { + /** Возвращает .getDescription() у enum через рефлексию, либо .name() как fallback */ + private String describeEnum(Enum e) { + if (e == null) return "не указано"; try { - return objectMapper.createObjectNode() - .put("captionIdea", captionIdea) - .set("analysis", objectMapper.valueToTree(analysis)) - .toString() - + "\nСтратегия: " + objectMapper.writeValueAsString(strategy); - } catch (Exception e) { - return "Идея: " + captionIdea + "\nАнализ: " + serializeToJson(analysis, "analysis"); + return (String) e.getClass().getMethod("getDescription").invoke(e); + } catch (Exception ex) { + return e.name(); } } - private String safeGet(String value, String fallback) { - return (value != null && !value.isBlank()) ? value : fallback; + private String safeStr(String s) { + return (s != null && !s.isBlank()) ? s : ""; } private String formatList(List list) { - if (list == null || list.isEmpty()) return "Не указано"; - return String.join(", ", list); + return (list == null || list.isEmpty()) ? "не указано" : String.join(", ", list); } } diff --git a/src/main/java/kz/konturai/parser/service/TargetingCampaignService.java b/src/main/java/kz/konturai/parser/service/TargetingCampaignService.java index 88200f6..1e183e9 100644 --- a/src/main/java/kz/konturai/parser/service/TargetingCampaignService.java +++ b/src/main/java/kz/konturai/parser/service/TargetingCampaignService.java @@ -4,6 +4,8 @@ import kz.konturai.parser.dto.StatusHistoryEntry; import kz.konturai.parser.dto.TargetingAIAnalysisResult; import kz.konturai.parser.dto.TargetingCampaignRequest; import kz.konturai.parser.dto.TargetingPerformanceDto; +import kz.konturai.parser.dto.targeting.AudienceProfileDto; +import kz.konturai.parser.dto.targeting.TargetingRecommendationDto; import kz.konturai.parser.exception.TargetingCampaignNotFoundException; import kz.konturai.parser.model.*; import kz.konturai.parser.repository.MarketingAnalysisV3Repository; @@ -22,9 +24,13 @@ import org.springframework.stereotype.Service; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Locale; +import java.util.Objects; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -34,6 +40,7 @@ public class TargetingCampaignService { private final TargetingCampaignRepository repository; private final MarketingAnalysisV3Repository analysisRepository; private final AiTargetologistService aiService; + private final AiTargetingSystemService aiTargetingSystemService; private final ABTestingService abTestingService; private final FacebookAdsService facebookAdsService; private final TikTokAdsService tikTokAdsService; @@ -42,21 +49,30 @@ public class TargetingCampaignService { private final MarketingStrategyRepository strategyRepository; public TargetingCampaign createCampaign(TargetingCampaignRequest req, String userId) { - if ((req.getAnalysisId() == null || req.getAnalysisId().isBlank()) && (req.getStrategyId() == null || req.getStrategyId().isBlank())) { - throw new IllegalArgumentException("analysisId or strategyId is required"); + if (req.getStrategyId() == null || req.getStrategyId().isBlank()) { + throw new IllegalArgumentException("strategyId is required"); } - - if (req.getStrategyId() != null && !req.getStrategyId().isBlank()) { - MarketingStrategy strategy = strategyRepository.findById(req.getStrategyId()) - .orElseThrow(() -> new IllegalArgumentException("Strategy not found: " + req.getStrategyId())); - if (req.getAnalysisId() == null || req.getAnalysisId().isBlank()) { - req.setAnalysisId(strategy.getAnalysisId()); - } + + MarketingStrategy strategy = strategyRepository.findById(req.getStrategyId()) + .orElseThrow(() -> new IllegalArgumentException("Strategy not found: " + req.getStrategyId())); + if (strategy.getAnalysisId() == null || strategy.getAnalysisId().isBlank()) { + throw new IllegalArgumentException("Strategy does not contain analysisId: " + req.getStrategyId()); } + + MarketingAnalysisV3Document analysis = analysisRepository.findById(strategy.getAnalysisId()) + .orElseThrow(() -> new IllegalArgumentException("Analysis not found: " + strategy.getAnalysisId())); + if (req.getTotalBudgetKzt() == null || req.getTotalBudgetKzt() <= 0) { throw new IllegalArgumentException("Valid budget is required"); } + TargetingRecommendationDto recommendation = + aiTargetingSystemService.generateLinkedRecommendation(strategy, analysis); + TargetingAudienceProfile audienceProfile = + buildAudienceProfile(recommendation, req.getAudienceOverride()); + TargetingAIAnalysisResult aiAnalysisResult = + buildAiAnalysisResult(strategy, analysis, recommendation); + BudgetConfig budget = BudgetConfig.builder() .totalBudget(req.getTotalBudgetKzt()) .dailyBudget(req.getDailyBudgetKzt()) @@ -66,12 +82,14 @@ public class TargetingCampaignService { TargetingCampaign campaign = TargetingCampaign.builder() .userId(userId) - .analysisId(req.getAnalysisId()) + .analysisId(strategy.getAnalysisId()) .strategyId(req.getStrategyId()) - .name(req.getCampaignName()) - .objective(CampaignObjective.valueOf(req.getObjective())) + .name(resolveCampaignName(req, strategy, recommendation)) + .objective(resolveObjective(req, recommendation)) .status("draft") .budget(budget) + .audience(audienceProfile) + .aiRecommendations(aiAnalysisResult) .createdAt(LocalDateTime.now()) .updatedAt(LocalDateTime.now()) .statusHistory(new ArrayList<>()) @@ -80,13 +98,9 @@ public class TargetingCampaignService { .build(); // Map platforms - if (req.getPlatforms() != null) { - List platforms = new ArrayList<>(); - req.getPlatforms().forEach(p -> platforms.add(kz.konturai.parser.enums.TargetingPlatform.valueOf(p))); - campaign.setPlatforms(platforms); - } + campaign.setPlatforms(resolvePlatforms(req, recommendation)); - addStatusHistory(campaign, "CREATED", "Campaign draft created, initiating AI pipeline"); + addStatusHistory(campaign, "CREATED", "Campaign draft created from strategy with AI targeting recommendation"); TargetingCampaign saved = repository.save(campaign); // Process Async @@ -108,12 +122,7 @@ public class TargetingCampaignService { return; } - MarketingStrategy strategy = null; - if (req.getStrategyId() != null) { - strategy = strategyRepository.findById(req.getStrategyId()).orElse(null); - } else { - strategy = strategyRepository.findFirstByAnalysisIdOrderByCreatedAtDesc(campaign.getAnalysisId()).orElse(null); - } + MarketingStrategy strategy = strategyRepository.findById(campaign.getStrategyId()).orElse(null); if (strategy == null) { failCampaign(campaign, "Marketing Strategy containing media assets not found."); return; @@ -346,4 +355,202 @@ public class TargetingCampaignService { .message(message) .build()); } + + private CampaignObjective resolveObjective( + TargetingCampaignRequest req, + TargetingRecommendationDto recommendation) { + String objective = req.getObjective(); + if (objective == null || objective.isBlank()) { + objective = recommendation != null ? recommendation.getCampaignObjective() : null; + } + if (objective == null || objective.isBlank()) { + return CampaignObjective.LEADS; + } + try { + return CampaignObjective.valueOf(objective.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ex) { + log.warn("Unknown campaign objective '{}', fallback to LEADS", objective); + return CampaignObjective.LEADS; + } + } + + private String resolveCampaignName( + TargetingCampaignRequest req, + MarketingStrategy strategy, + TargetingRecommendationDto recommendation) { + if (req.getCampaignName() != null && !req.getCampaignName().isBlank()) { + return req.getCampaignName().trim(); + } + + List nameParts = new ArrayList<>(); + nameParts.add("Targeting"); + if (strategy.getScoringModelTitle() != null && !strategy.getScoringModelTitle().isBlank()) { + nameParts.add(strategy.getScoringModelTitle().trim()); + } + if (recommendation != null + && recommendation.getRecommendedTypeLabel() != null + && !recommendation.getRecommendedTypeLabel().isBlank()) { + nameParts.add(recommendation.getRecommendedTypeLabel().trim()); + } + return String.join(" | ", nameParts); + } + + private List resolvePlatforms( + TargetingCampaignRequest req, + TargetingRecommendationDto recommendation) { + List rawPlatforms = req.getPlatforms(); + if (rawPlatforms == null || rawPlatforms.isEmpty()) { + rawPlatforms = recommendation != null ? recommendation.getRecommendedPlatforms() : Collections.emptyList(); + } + if (rawPlatforms == null || rawPlatforms.isEmpty()) { + return List.of( + kz.konturai.parser.enums.TargetingPlatform.INSTAGRAM, + kz.konturai.parser.enums.TargetingPlatform.FACEBOOK + ); + } + + return rawPlatforms.stream() + .map(this::mapPlatform) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toList()); + } + + private kz.konturai.parser.enums.TargetingPlatform mapPlatform(String platform) { + if (platform == null || platform.isBlank()) { + return null; + } + String normalized = platform.trim().toUpperCase(Locale.ROOT); + if ("META".equals(normalized)) { + return kz.konturai.parser.enums.TargetingPlatform.FACEBOOK; + } + try { + return kz.konturai.parser.enums.TargetingPlatform.valueOf(normalized); + } catch (IllegalArgumentException ex) { + log.warn("Unknown targeting platform '{}', skipping", platform); + return null; + } + } + + private TargetingAIAnalysisResult buildAiAnalysisResult( + MarketingStrategy strategy, + MarketingAnalysisV3Document analysis, + TargetingRecommendationDto recommendation) { + return TargetingAIAnalysisResult.builder() + .strategyId(strategy.getId()) + .analysisId(strategy.getAnalysisId()) + .businessNiche(analysis.getRequestData() != null ? analysis.getRequestData().getBusinessNiche() : null) + .productName(analysis.getRequestData() != null ? analysis.getRequestData().getProductName() : null) + .goal(analysis.getRequestData() != null ? analysis.getRequestData().getGoal() : null) + .recommendedTargetingType(recommendation != null ? recommendation.getRecommendedType() : null) + .recommendedTargetingTypeLabel(recommendation != null ? recommendation.getRecommendedTypeLabel() : null) + .recommendedCampaignObjective(recommendation != null ? recommendation.getCampaignObjective() : null) + .questionnaireSignals(recommendation != null ? recommendation.getQuestionnaireSinals() : List.of()) + .aiRecommendationsRationale(recommendation != null ? recommendation.getTypeRationale() : null) + .quickWins(recommendation != null ? recommendation.getQuickWins() : List.of()) + .warnings(recommendation != null ? recommendation.getWarnings() : List.of()) + .targetingRecommendation(recommendation) + .build(); + } + + private TargetingAudienceProfile buildAudienceProfile( + TargetingRecommendationDto recommendation, + kz.konturai.parser.dto.AudienceOverrideDto overrideDto) { + AudienceProfileDto aiAudience = recommendation != null ? recommendation.getAudienceProfile() : null; + + int[] ageRange = parseAgeRange(aiAudience != null ? aiAudience.getAgeRange() : null); + int ageMin = overrideDto != null && overrideDto.getAgeMin() > 0 ? overrideDto.getAgeMin() : ageRange[0]; + int ageMax = overrideDto != null && overrideDto.getAgeMax() > 0 ? overrideDto.getAgeMax() : ageRange[1]; + + List interests = new ArrayList<>(); + if (aiAudience != null && aiAudience.getInterests() != null) { + interests.addAll(aiAudience.getInterests()); + } + if (overrideDto != null && overrideDto.getAdditionalInterests() != null) { + interests.addAll(overrideDto.getAdditionalInterests()); + } + + List cities = new ArrayList<>(); + if (overrideDto != null && overrideDto.getCities() != null && !overrideDto.getCities().isEmpty()) { + cities.addAll(overrideDto.getCities()); + } else if (aiAudience != null && aiAudience.getGeography() != null) { + cities.addAll(aiAudience.getGeography()); + } + + List genders = overrideDto != null && overrideDto.getGenders() != null && !overrideDto.getGenders().isEmpty() + ? overrideDto.getGenders() + : inferGenders(aiAudience != null ? aiAudience.getGender() : null); + + List locations = cities.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(city -> !city.isBlank()) + .distinct() + .map(city -> LocationTarget.builder() + .cityName(city) + .country("KZ") + .radius(25.0) + .build()) + .collect(Collectors.toList()); + + return TargetingAudienceProfile.builder() + .ageMin(ageMin) + .ageMax(ageMax) + .genders(genders) + .locations(locations) + .interests(interests.stream().filter(Objects::nonNull).map(String::trim).filter(s -> !s.isBlank()).distinct().collect(Collectors.toList())) + .behaviors(aiAudience != null && aiAudience.getBehaviors() != null ? aiAudience.getBehaviors() : List.of()) + .languages(splitLanguages(aiAudience != null ? aiAudience.getLanguage() : null)) + .excludedAudiences(aiAudience != null && aiAudience.getExclusions() != null ? aiAudience.getExclusions() : List.of()) + .deviceTypes(List.of("mobile", "desktop")) + .connectionType("ALL") + .audienceScore(85) + .build(); + } + + private int[] parseAgeRange(String rawAgeRange) { + if (rawAgeRange == null || rawAgeRange.isBlank()) { + return new int[]{25, 45}; + } + String[] parts = rawAgeRange.replaceAll("[^0-9-]", "").split("-"); + if (parts.length == 2) { + try { + return new int[]{Integer.parseInt(parts[0]), Integer.parseInt(parts[1])}; + } catch (NumberFormatException ignored) { + return new int[]{25, 45}; + } + } + return new int[]{25, 45}; + } + + private List inferGenders(String genderText) { + if (genderText == null || genderText.isBlank()) { + return List.of("ALL"); + } + String normalized = genderText.toLowerCase(Locale.ROOT); + boolean hasWomen = normalized.contains("жен"); + boolean hasMen = normalized.contains("муж"); + if (hasWomen && hasMen) { + return List.of("FEMALE", "MALE"); + } + if (hasWomen) { + return List.of("FEMALE"); + } + if (hasMen) { + return List.of("MALE"); + } + return List.of("ALL"); + } + + private List splitLanguages(String languagesText) { + if (languagesText == null || languagesText.isBlank()) { + return List.of("Русский"); + } + return List.of(languagesText.split(",")) + .stream() + .map(String::trim) + .filter(s -> !s.isBlank()) + .distinct() + .collect(Collectors.toList()); + } }