diff --git a/src/main/java/kz/konturai/parser/controller/AiTargetingSystemController.java b/src/main/java/kz/konturai/parser/controller/AiTargetingSystemController.java new file mode 100644 index 0000000..3278e08 --- /dev/null +++ b/src/main/java/kz/konturai/parser/controller/AiTargetingSystemController.java @@ -0,0 +1,120 @@ +package kz.konturai.parser.controller; + +import jakarta.validation.Valid; +import kz.konturai.parser.dto.targeting.AdCreativeDto; +import kz.konturai.parser.dto.targeting.CreativeRequest; +import kz.konturai.parser.dto.targeting.MarketingAnalysisDto; +import kz.konturai.parser.dto.targeting.MarketingStrategyDto; +import kz.konturai.parser.dto.targeting.StrategyRequest; +import kz.konturai.parser.dto.targeting.TargetingLaunchResultDto; +import kz.konturai.parser.dto.targeting.TopicRequest; +import kz.konturai.parser.service.AiTargetingSystemService; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Stateless AI Targeting System API. + * + * Flow: POST /analysis → POST /strategy → POST /creative + * + * No database involved — each endpoint takes the previous step's output + * as part of the request body and enriches it with AI-generated content. + */ +@RestController +@RequestMapping("/api/targeting") +@RequiredArgsConstructor +public class AiTargetingSystemController { + + private static final Logger logger = LoggerFactory.getLogger(AiTargetingSystemController.class); + + private final AiTargetingSystemService aiTargetingSystemService; + + /** + * STEP 1 — Marketing Analysis. + * + * Accepts a business topic and returns a structured marketing analysis + * (target audience, platforms, budget, key messages, etc.). + * + * POST /api/targeting/analysis + * Body: { "topic": "детский сад в Алматы" } + */ + @PostMapping("/analysis") + public ResponseEntity generateAnalysis( + @RequestBody @Valid TopicRequest request) { + + logger.info("POST /api/targeting/analysis — topic: {}", request.getTopic()); + MarketingAnalysisDto result = aiTargetingSystemService.generateAnalysis(request.getTopic()); + return ResponseEntity.ok(result); + } + + /** + * STEP 2 — Marketing Strategy. + * + * Accepts the full analysis DTO and returns a 3-phase 30-day campaign strategy + * with ad creatives brief and expected KPIs. + * + * POST /api/targeting/strategy + * Body: { "analysis": { ...MarketingAnalysisDto... } } + */ + @PostMapping("/strategy") + public ResponseEntity generateStrategy( + @RequestBody @Valid StrategyRequest request) { + + logger.info("POST /api/targeting/strategy — topic: {}", + request.getAnalysis().getTopic()); + MarketingStrategyDto result = aiTargetingSystemService.generateStrategy(request.getAnalysis()); + return ResponseEntity.ok(result); + } + + /** + * STEP 3 — Ad Creative. + * + * Accepts a creative brief (captionIdea + full analysis + full strategy) + * and returns a ready-to-launch ad post: headline, caption, hashtags, + * CTA button, and targeting settings. + * + * POST /api/targeting/creative + * Body: { + * "captionIdea": "string", + * "analysis": { ...MarketingAnalysisDto... }, + * "strategy": { ...MarketingStrategyDto... } + * } + */ + @PostMapping("/creative") + public ResponseEntity generateCreative( + @RequestBody @Valid CreativeRequest request) { + + logger.info("POST /api/targeting/creative — captionIdea: {}", request.getCaptionIdea()); + AdCreativeDto result = aiTargetingSystemService.generateCreative( + request.getCaptionIdea(), + request.getAnalysis(), + request.getStrategy()); + return ResponseEntity.ok(result); + } + + /** + * STEP V3 — Launch Targeting from existing V3 Strategy. + * + * Берёт готовую V3-стратегию из MongoDB, генерирует настройки таргетинга + * через AI на основе V3-анализа и публикует первый пост из postCalendar в Facebook. + * + * Требования: стратегия должна иметь статус "completed" и непустой postCalendar. + * + * POST /api/targeting/launch/{strategyId} + */ + @PostMapping("/launch/{strategyId}") + public ResponseEntity launchFromStrategy( + @PathVariable String strategyId) { + + logger.info("POST /api/targeting/launch/{} — launching targeting from V3 strategy", strategyId); + TargetingLaunchResultDto result = aiTargetingSystemService.launchTargetingFromStrategy(strategyId); + return ResponseEntity.ok(result); + } +} diff --git a/src/main/java/kz/konturai/parser/controller/GlobalExceptionHandler.java b/src/main/java/kz/konturai/parser/controller/GlobalExceptionHandler.java new file mode 100644 index 0000000..1acaac3 --- /dev/null +++ b/src/main/java/kz/konturai/parser/controller/GlobalExceptionHandler.java @@ -0,0 +1,59 @@ +package kz.konturai.parser.controller; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(IllegalStateException.class) + public ResponseEntity> handleIllegalState(IllegalStateException e) { + logger.error("AI processing error: {}", e.getMessage()); + return ResponseEntity.status(500).body(errorBody("AI_PROCESSING_ERROR", e.getMessage())); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgument(IllegalArgumentException e) { + logger.warn("Invalid argument: {}", e.getMessage()); + return ResponseEntity.badRequest().body(errorBody("INVALID_ARGUMENT", e.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation(MethodArgumentNotValidException e) { + Map fieldErrors = e.getBindingResult().getFieldErrors() + .stream() + .collect(Collectors.toMap( + FieldError::getField, + fe -> fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "Некорректное значение", + (existing, duplicate) -> existing + )); + Map body = new LinkedHashMap<>(); + body.put("error", "VALIDATION_ERROR"); + body.put("fields", fieldErrors); + return ResponseEntity.badRequest().body(body); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGeneral(Exception e) { + logger.error("Unexpected error", e); + return ResponseEntity.status(500).body(errorBody("INTERNAL_ERROR", e.getMessage())); + } + + private Map errorBody(String code, String message) { + Map body = new LinkedHashMap<>(); + body.put("error", code); + body.put("message", message); + return body; + } +} diff --git a/src/main/java/kz/konturai/parser/dto/targeting/AdCreativeDto.java b/src/main/java/kz/konturai/parser/dto/targeting/AdCreativeDto.java new file mode 100644 index 0000000..44336b8 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/targeting/AdCreativeDto.java @@ -0,0 +1,23 @@ +package kz.konturai.parser.dto.targeting; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class AdCreativeDto { + + private String headline; + private String caption; + private List hashtags; + private String ctaButton; + private TargetingSettingsDto targetingSettings; +} diff --git a/src/main/java/kz/konturai/parser/dto/targeting/AdCreativeNeedDto.java b/src/main/java/kz/konturai/parser/dto/targeting/AdCreativeNeedDto.java new file mode 100644 index 0000000..eea4ec4 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/targeting/AdCreativeNeedDto.java @@ -0,0 +1,23 @@ +package kz.konturai.parser.dto.targeting; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class AdCreativeNeedDto { + + private int id; + private String type; + private String theme; + private String captionIdea; + private String imageDescription; + private String cta; + private String targetSegment; +} diff --git a/src/main/java/kz/konturai/parser/dto/targeting/BudgetRecommendationDto.java b/src/main/java/kz/konturai/parser/dto/targeting/BudgetRecommendationDto.java new file mode 100644 index 0000000..87bbccf --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/targeting/BudgetRecommendationDto.java @@ -0,0 +1,19 @@ +package kz.konturai.parser.dto.targeting; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class BudgetRecommendationDto { + + private String minDaily; + private String optimalDaily; + private String monthly; +} diff --git a/src/main/java/kz/konturai/parser/dto/targeting/CreativeRequest.java b/src/main/java/kz/konturai/parser/dto/targeting/CreativeRequest.java new file mode 100644 index 0000000..6d6382a --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/targeting/CreativeRequest.java @@ -0,0 +1,21 @@ +package kz.konturai.parser.dto.targeting; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class CreativeRequest { + + @NotBlank(message = "Идея подписи не может быть пустой") + private String captionIdea; + + @NotNull(message = "Данные анализа обязательны") + @Valid + private MarketingAnalysisDto analysis; + + @NotNull(message = "Данные стратегии обязательны") + @Valid + private MarketingStrategyDto strategy; +} diff --git a/src/main/java/kz/konturai/parser/dto/targeting/ExpectedResultsDto.java b/src/main/java/kz/konturai/parser/dto/targeting/ExpectedResultsDto.java new file mode 100644 index 0000000..bd77d22 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/targeting/ExpectedResultsDto.java @@ -0,0 +1,20 @@ +package kz.konturai.parser.dto.targeting; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class ExpectedResultsDto { + + private String reach; + private String clicks; + private String conversions; + private String cpl; +} diff --git a/src/main/java/kz/konturai/parser/dto/targeting/MarketingAnalysisDto.java b/src/main/java/kz/konturai/parser/dto/targeting/MarketingAnalysisDto.java new file mode 100644 index 0000000..57e0781 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/targeting/MarketingAnalysisDto.java @@ -0,0 +1,28 @@ +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 MarketingAnalysisDto { + + private String topic; + private String marketOverview; + private TargetAudienceDto targetAudience; + private List platforms; + private List bestAdFormats; + private List postingTimes; + private BudgetRecommendationDto budgetRecommendation; + private List keyMessages; + private String competitorsInsight; + private String recommendedVisuals; +} diff --git a/src/main/java/kz/konturai/parser/dto/targeting/MarketingStrategyDto.java b/src/main/java/kz/konturai/parser/dto/targeting/MarketingStrategyDto.java new file mode 100644 index 0000000..145c5c5 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/targeting/MarketingStrategyDto.java @@ -0,0 +1,25 @@ +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 MarketingStrategyDto { + + private String strategyName; + private String goal; + private String duration; + private List phases; + private List adCreativesNeeded; + private String totalBudget; + private ExpectedResultsDto expectedResults; +} diff --git a/src/main/java/kz/konturai/parser/dto/targeting/PhaseDto.java b/src/main/java/kz/konturai/parser/dto/targeting/PhaseDto.java new file mode 100644 index 0000000..c22c566 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/targeting/PhaseDto.java @@ -0,0 +1,24 @@ +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 PhaseDto { + + private int phase; + private String name; + private String duration; + private String objective; + private List tactics; + private String kpi; +} diff --git a/src/main/java/kz/konturai/parser/dto/targeting/PlatformDto.java b/src/main/java/kz/konturai/parser/dto/targeting/PlatformDto.java new file mode 100644 index 0000000..785a053 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/targeting/PlatformDto.java @@ -0,0 +1,19 @@ +package kz.konturai.parser.dto.targeting; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class PlatformDto { + + private String name; + private String priority; + private String reason; +} diff --git a/src/main/java/kz/konturai/parser/dto/targeting/StrategyRequest.java b/src/main/java/kz/konturai/parser/dto/targeting/StrategyRequest.java new file mode 100644 index 0000000..1fec7be --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/targeting/StrategyRequest.java @@ -0,0 +1,13 @@ +package kz.konturai.parser.dto.targeting; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class StrategyRequest { + + @NotNull(message = "Данные анализа обязательны") + @Valid + private MarketingAnalysisDto analysis; +} diff --git a/src/main/java/kz/konturai/parser/dto/targeting/TargetAudienceDto.java b/src/main/java/kz/konturai/parser/dto/targeting/TargetAudienceDto.java new file mode 100644 index 0000000..15d7948 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/targeting/TargetAudienceDto.java @@ -0,0 +1,26 @@ +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 TargetAudienceDto { + + private String primary; + private String secondary; + private String ageRange; + private String gender; + private String income; + private List interests; + private List painPoints; + private List motivations; +} diff --git a/src/main/java/kz/konturai/parser/dto/targeting/TargetingLaunchResultDto.java b/src/main/java/kz/konturai/parser/dto/targeting/TargetingLaunchResultDto.java new file mode 100644 index 0000000..0af095a --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/targeting/TargetingLaunchResultDto.java @@ -0,0 +1,41 @@ +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 TargetingLaunchResultDto { + + // ── Источник ────────────────────────────────────────────────────────────── + private String strategyId; + private String analysisId; + private String scoringModelName; + private String scoringModelTitle; + + // ── Первый опубликованный пост ──────────────────────────────────────────── + private int postIndex; + private String platform; + private String contentType; // "фото" / "видео" + private String theme; + private String postText; + private List hashtags; + private String imageUrl; + private boolean imagePosted; + + // ── Facebook ────────────────────────────────────────────────────────────── + private String facebookPostId; + private String facebookStatus; // "PUBLISHED" | "FAILED" | "TEXT_ONLY" + + // ── Таргетинг (генерируется AI из V3 анализа) ──────────────────────────── + private TargetingSettingsDto targetingSettings; + + // ── Сводка ─────────────────────────────────────────────────────────────── + private String message; +} diff --git a/src/main/java/kz/konturai/parser/dto/targeting/TargetingSettingsDto.java b/src/main/java/kz/konturai/parser/dto/targeting/TargetingSettingsDto.java new file mode 100644 index 0000000..1241a1e --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/targeting/TargetingSettingsDto.java @@ -0,0 +1,24 @@ +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 TargetingSettingsDto { + + private String audience; + private String age; + private List interests; + private String placement; + private String budgetPerDay; + private String estimatedReach; +} diff --git a/src/main/java/kz/konturai/parser/dto/targeting/TopicRequest.java b/src/main/java/kz/konturai/parser/dto/targeting/TopicRequest.java new file mode 100644 index 0000000..1ef40cd --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/targeting/TopicRequest.java @@ -0,0 +1,13 @@ +package kz.konturai.parser.dto.targeting; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class TopicRequest { + + @NotBlank(message = "Тема не может быть пустой") + @Size(min = 3, max = 300, message = "Тема должна содержать от 3 до 300 символов") + private String topic; +} diff --git a/src/main/java/kz/konturai/parser/service/AiTargetingSystemService.java b/src/main/java/kz/konturai/parser/service/AiTargetingSystemService.java new file mode 100644 index 0000000..359177f --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/AiTargetingSystemService.java @@ -0,0 +1,569 @@ +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.model.MarketingAnalysisV3Document; +import kz.konturai.parser.model.MarketingStrategy; +import kz.konturai.parser.repository.MarketingAnalysisV3Repository; +import kz.konturai.parser.repository.MarketingStrategyRepository; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +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. + * + * Два режима работы: + * 1. STATELESS — generateAnalysis / generateStrategy / generateCreative + * Для React-приложения: принимает тему, возвращает структуры без БД. + * + * 2. V3-LINKED — launchTargetingFromStrategy(strategyId) + * Для полного цикла: берёт готовую V3-стратегию из MongoDB, + * генерирует настройки таргетинга из V3-анализа через AI + * и сразу публикует первый пост в Facebook. + */ +@Service +@RequiredArgsConstructor +public class AiTargetingSystemService { + + private static final Logger logger = LoggerFactory.getLogger(AiTargetingSystemService.class); + + // ── Существующие сервисы ────────────────────────────────────────────────── + private final OpenAIAnalyticsService openAIAnalyticsService; + private final ObjectMapper objectMapper; + private final MarketingStrategyRepository strategyRepository; + private final MarketingAnalysisV3Repository analysisV3Repository; + private final FacebookPostingService facebookPostingService; + private final MinIOService minIOService; + + // ── Конфиг моделей (из application.properties targeting.ai.*) ──────────── + @Value("${targeting.ai.audience-model:gpt-4o}") + private String analysisModel; + + @Value("${targeting.ai.creatives-model:gpt-4o}") + private String strategyModel; + + @Value("${targeting.ai.creatives-model:gpt-4o}") + private String creativeModel; + + @Value("${targeting.ai.max-audience-tokens:2500}") + private int analysisMaxTokens; + + @Value("${targeting.ai.max-creatives-tokens:2000}") + private int strategyMaxTokens; + + @Value("${targeting.ai.max-creatives-tokens:2000}") + private int creativeMaxTokens; + + // ═══════════════════════════════════════════════════════════════════════════ + // V3-LINKED: Запуск таргетинга из существующей V3-стратегии + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * Запускает таргетинг на основе готовой V3-стратегии: + * 1. Загружает {@link MarketingStrategy} по strategyId из MongoDB. + * 2. Загружает связанный {@link MarketingAnalysisV3Document}. + * 3. Берёт первый пост из postCalendar. + * 4. Генерирует настройки таргетинга через AI на основе V3-анализа. + * 5. Публикует пост в Facebook (с изображением если есть). + * + * @param strategyId ID стратегии из коллекции marketing_strategy + * @return результат запуска с Facebook postId и настройками таргетинга + */ + public TargetingLaunchResultDto launchTargetingFromStrategy(String strategyId) { + logger.info("Launching targeting from V3 strategy: {}", strategyId); + + // 1. Загружаем стратегию + MarketingStrategy strategy = strategyRepository.findById(strategyId) + .orElseThrow(() -> new IllegalArgumentException( + "Стратегия не найдена: " + strategyId)); + + if (!"completed".equalsIgnoreCase(strategy.getStatus())) { + throw new IllegalStateException( + "Стратегия ещё не готова. Текущий статус: " + strategy.getStatus()); + } + + List calendar = strategy.getPostCalendar(); + if (calendar == null || calendar.isEmpty()) { + throw new IllegalStateException("Календарь постов пуст для стратегии: " + strategyId); + } + + // 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()); + + // 4. Генерируем настройки таргетинга через AI + TargetingSettingsDto targetingSettings = generateTargetingSettingsFromV3(analysis); + + // 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"; + } + + 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)) + .build(); + } + + /** + * Генерирует настройки таргетинга через AI из V3-анализа. + * Использует requestData (ниша, города, поведение) + resultData (CII, конкуренты) + * чтобы сформировать рекомендации по аудитории, бюджету и размещению. + */ + private TargetingSettingsDto generateTargetingSettingsFromV3(MarketingAnalysisV3Document analysis) { + MarketingAnalysisV3Request req = analysis.getRequestData(); + MarketingAnalysisV3Result res = analysis.getResultData(); + + String systemPrompt = """ + Ты — эксперт по платному трафику и таргетированной рекламе на рынке Казахстана. + На основе данных маркетингового анализа сформируй оптимальные настройки таргетинга. + + Возвращай ТОЛЬКО валидный JSON без markdown, без пояснений, без блоков кода. + Чистый JSON-объект и ничего более. Весь текст — на русском языке. + """; + + 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 человек/день" + } + + Данные для анализа: + Ниша: %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, "Не указано") + ); + + // Обогащаем контекст данными из 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); + } + + try { + String json = extractJson(rawResponse); + return objectMapper.readValue(json, TargetingSettingsDto.class); + } catch (Exception e) { + logger.warn("Failed to parse targeting settings from AI, using defaults. Error: {}", e.getMessage()); + return buildDefaultTargetingSettings(req); + } + } + + /** + * Формирует читаемый контекст из resultData V3-анализа для AI-промпта. + */ + private String buildV3AnalysisContext(MarketingAnalysisV3Result res) { + if (res == null) return ""; + + 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"] + }, + "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 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": "Осведомлённость", + "duration": "10 дней", + "objective": "что достигаем", + "tactics": ["тактика1", "тактика2", "тактика3"], + "kpi": "ожидаемый KPI" + }, + { + "phase": 2, + "name": "Вовлечённость", + "duration": "10 дней", + "objective": "...", + "tactics": ["..."], + "kpi": "..." + }, + { + "phase": 3, + "name": "Конверсия", + "duration": "10 дней", + "objective": "...", + "tactics": ["..."], + "kpi": "..." + } + ], + "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": "..." + } + ], + "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 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) { + + logger.info("Generating ad creative for idea: {}", captionIdea); + + String systemPrompt = """ + Ты — профессиональный копирайтер для рекламы в социальных сетях. + Специализируешься на рынке Казахстана, пишешь эмоционально и убедительно. + Твои тексты всегда включают хук, выгоды, социальное доказательство и призыв к действию. + + Возвращай ТОЛЬКО валидный JSON без markdown, без пояснений, без блоков кода. + Чистый JSON-объект и ничего более. Весь текст внутри JSON — на русском языке. + """; + + 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": "бюджет из анализа в KZT", + "estimatedReach": "например 5 000 - 15 000 человек/день" + } + } + + Данные для создания поста: + """; + + String contextJson = buildCreativeContext(captionIdea, analysis, strategy); + String rawResponse = openAIAnalyticsService.generateWithInstructionWithModel( + contextJson, instruction, "ru", creativeModel, systemPrompt, creativeMaxTokens, 90000L); + + return parseResponse(rawResponse, AdCreativeDto.class, "Ad Creative"); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // HELPERS + // ═══════════════════════════════════════════════════════════════════════════ + + private String extractJson(String rawResponse) { + if (rawResponse == null || rawResponse.isBlank()) { + throw new IllegalStateException("Пустой ответ от AI"); + } + 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); + } + + private T parseResponse(String rawResponse, Class type, String context) { + if (rawResponse == null) { + throw new IllegalStateException("AI не вернул ответ для: " + context); + } + 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()); + } + } + + private String serializeToJson(Object obj, String context) { + try { + return objectMapper.writeValueAsString(obj); + } catch (Exception e) { + throw new IllegalStateException("Ошибка сериализации " + context + ": " + e.getMessage()); + } + } + + private String buildCreativeContext(String captionIdea, MarketingAnalysisDto analysis, MarketingStrategyDto strategy) { + 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"); + } + } + + private String safeGet(String value, String fallback) { + return (value != null && !value.isBlank()) ? value : fallback; + } + + private String formatList(List list) { + if (list == null || list.isEmpty()) return "Не указано"; + return String.join(", ", list); + } +}