From 630ddb956114f206b4dc5c68aa61247e3a809aa9 Mon Sep 17 00:00:00 2001 From: arys Date: Fri, 3 Apr 2026 10:20:17 +0500 Subject: [PATCH] target fix --- .../controller/GlobalExceptionHandler.java | 43 ++- .../TargetingCampaignController.java | 122 +++++++- .../targeting/TargetingRecommendationDto.java | 2 +- .../parser/enums/CampaignStatusMessage.java | 23 ++ .../parser/exception/ClaudeApiException.java | 10 + .../parser/exception/ValidationException.java | 22 ++ .../parser/model/CampaignPrediction.java | 151 ++++++++++ .../parser/model/PerformanceMetrics.java | 67 ++++- .../kz/konturai/parser/model/TargetingAd.java | 5 +- .../parser/model/TargetingCampaign.java | 3 + .../CampaignPredictionRepository.java | 15 + .../service/AiTargetingSystemService.java | 2 +- .../service/CampaignPredictionService.java | 265 +++++++++++++++++ .../parser/service/ClaudeApiService.java | 131 +++++++++ .../service/TargetingCampaignService.java | 267 ++++++++++++++---- .../kz/konturai/parser/util/RangeParser.java | 59 ++++ src/main/resources/application.properties | 3 + 17 files changed, 1120 insertions(+), 70 deletions(-) create mode 100644 src/main/java/kz/konturai/parser/enums/CampaignStatusMessage.java create mode 100644 src/main/java/kz/konturai/parser/exception/ClaudeApiException.java create mode 100644 src/main/java/kz/konturai/parser/exception/ValidationException.java create mode 100644 src/main/java/kz/konturai/parser/model/CampaignPrediction.java create mode 100644 src/main/java/kz/konturai/parser/repository/CampaignPredictionRepository.java create mode 100644 src/main/java/kz/konturai/parser/service/CampaignPredictionService.java create mode 100644 src/main/java/kz/konturai/parser/service/ClaudeApiService.java create mode 100644 src/main/java/kz/konturai/parser/util/RangeParser.java diff --git a/src/main/java/kz/konturai/parser/controller/GlobalExceptionHandler.java b/src/main/java/kz/konturai/parser/controller/GlobalExceptionHandler.java index 8dbacd6..4ef79cf 100644 --- a/src/main/java/kz/konturai/parser/controller/GlobalExceptionHandler.java +++ b/src/main/java/kz/konturai/parser/controller/GlobalExceptionHandler.java @@ -1,5 +1,8 @@ package kz.konturai.parser.controller; +import kz.konturai.parser.exception.ClaudeApiException; +import kz.konturai.parser.exception.TargetingCampaignNotFoundException; +import kz.konturai.parser.exception.ValidationException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.ResponseEntity; @@ -18,15 +21,41 @@ public class GlobalExceptionHandler { private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); + @ExceptionHandler(ValidationException.class) + public ResponseEntity> handleValidationException(ValidationException e) { + logger.warn("Ошибка валидации: field={}, value={}, message={}", e.getField(), e.getValue(), e.getMessage()); + Map body = new LinkedHashMap<>(); + body.put("error", "VALIDATION_ERROR"); + body.put("message", e.getMessage()); + if (e.getField() != null) { + body.put("field", e.getField()); + body.put("value", e.getValue()); + } + return ResponseEntity.badRequest().body(body); + } + + @ExceptionHandler(ClaudeApiException.class) + public ResponseEntity> handleClaudeApi(ClaudeApiException e) { + logger.error("Ошибка Claude API: {}", e.getMessage()); + return ResponseEntity.status(502).body(errorBody("CLAUDE_API_ERROR", + "Ошибка AI-сервиса: " + e.getMessage())); + } + + @ExceptionHandler(TargetingCampaignNotFoundException.class) + public ResponseEntity> handleNotFound(TargetingCampaignNotFoundException e) { + logger.warn("Кампания не найдена: {}", e.getMessage()); + return ResponseEntity.status(404).body(errorBody("NOT_FOUND", e.getMessage())); + } + @ExceptionHandler(IllegalStateException.class) public ResponseEntity> handleIllegalState(IllegalStateException e) { - logger.error("AI processing error: {}", e.getMessage()); + logger.error("Ошибка обработки AI: {}", 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()); + logger.warn("Некорректный аргумент: {}", e.getMessage()); return ResponseEntity.badRequest().body(errorBody("INVALID_ARGUMENT", e.getMessage())); } @@ -41,20 +70,22 @@ public class GlobalExceptionHandler { )); Map body = new LinkedHashMap<>(); body.put("error", "VALIDATION_ERROR"); + body.put("message", "Ошибка валидации запроса"); body.put("fields", fieldErrors); return ResponseEntity.badRequest().body(body); } @ExceptionHandler(NoResourceFoundException.class) public ResponseEntity> handleNoResourceFound(NoResourceFoundException e) { - logger.warn("Resource not found: {}", e.getResourcePath()); - return ResponseEntity.status(404).body(errorBody("NOT_FOUND", "Resource not found")); + logger.warn("Ресурс не найден: {}", e.getResourcePath()); + return ResponseEntity.status(404).body(errorBody("NOT_FOUND", "Ресурс не найден")); } @ExceptionHandler(Exception.class) public ResponseEntity> handleGeneral(Exception e) { - logger.error("Unexpected error", e); - return ResponseEntity.status(500).body(errorBody("INTERNAL_ERROR", e.getMessage())); + logger.error("Неожиданная ошибка", e); + return ResponseEntity.status(500).body(errorBody("INTERNAL_ERROR", + "Внутренняя ошибка сервера. Обратитесь в поддержку.")); } private Map errorBody(String code, String message) { diff --git a/src/main/java/kz/konturai/parser/controller/TargetingCampaignController.java b/src/main/java/kz/konturai/parser/controller/TargetingCampaignController.java index ab6df73..e201c57 100644 --- a/src/main/java/kz/konturai/parser/controller/TargetingCampaignController.java +++ b/src/main/java/kz/konturai/parser/controller/TargetingCampaignController.java @@ -1,9 +1,7 @@ package kz.konturai.parser.controller; import kz.konturai.parser.dto.*; -import kz.konturai.parser.model.MarketingAnalysisV3Document; -import kz.konturai.parser.model.TargetingAudienceProfile; -import kz.konturai.parser.model.TargetingCampaign; +import kz.konturai.parser.model.*; import kz.konturai.parser.repository.MarketingAnalysisV3Repository; import kz.konturai.parser.service.*; import lombok.RequiredArgsConstructor; @@ -25,12 +23,11 @@ public class TargetingCampaignController { private final TargetingCampaignService campaignService; private final AudienceSegmentationService audienceService; private final BudgetOptimizerService budgetOptimizerService; -// private final FacebookAdsService facebookAdsService; -// private final TikTokAdsService tikTokAdsService; private final ABTestingService abTestingService; private final MarketingAnalysisV3Repository analysisRepository; private final SocialMediaCredentialsService credentialsService; private final JwtService jwtService; + private final CampaignPredictionService predictionService; private String resolveUserId(String userIdHeader, String authHeader) { if (StringUtils.hasText(userIdHeader)) { @@ -243,4 +240,119 @@ public class TargetingCampaignController { // log.info("[Targeting] Get TT Advertisers"); // return ResponseEntity.ok(tikTokAdsService.getAdvertisers(token.replace("Bearer ", ""))); // } + + // ── Polling статуса ─────────────────────────────────────────────────────── + + @GetMapping("/campaigns/{id}/status") + public ResponseEntity> getCampaignStatus( + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "Authorization", required = false) String authHeader, + @PathVariable String id) { + String userId = resolveUserId(userIdHeader, authHeader); + TargetingCampaign campaign = campaignService.getCampaignById(id, userId); + return ResponseEntity.ok(Map.of( + "id", campaign.getId(), + "status", campaign.getStatus(), + "completedAt", campaign.getCompletedAt() != null ? campaign.getCompletedAt().toString() : "", + "statusHistory", campaign.getStatusHistory() != null ? campaign.getStatusHistory() : List.of() + )); + } + + // ── AI Предсказания ─────────────────────────────────────────────────────── + + @GetMapping("/campaigns/{id}/prediction") + public ResponseEntity getPrediction( + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "Authorization", required = false) String authHeader, + @PathVariable String id) { + String userId = resolveUserId(userIdHeader, authHeader); + campaignService.getCampaignById(id, userId); // проверка прав + return predictionService.getLatest(id) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @PostMapping("/campaigns/{id}/predict") + public ResponseEntity triggerPrediction( + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "Authorization", required = false) String authHeader, + @PathVariable String id) { + String userId = resolveUserId(userIdHeader, authHeader); + TargetingCampaign campaign = campaignService.getCampaignById(id, userId); + log.info("[Targeting] Manual predict trigger for campaign {}", id); + predictionService.generateAsync(campaign); + return ResponseEntity.accepted().body(Map.of("message", "Генерация предсказания запущена")); + } + + // ── Инсайты ─────────────────────────────────────────────────────────────── + + @GetMapping("/campaigns/{id}/insights") + public ResponseEntity getInsights( + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "Authorization", required = false) String authHeader, + @PathVariable String id) { + String userId = resolveUserId(userIdHeader, authHeader); + TargetingCampaign campaign = campaignService.getCampaignById(id, userId); + return ResponseEntity.ok(campaign.getInsights() != null ? campaign.getInsights() : List.of()); + } + + // ── Retry ───────────────────────────────────────────────────────────────── + + @PostMapping("/campaigns/{id}/retry") + public ResponseEntity retryCampaign( + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "Authorization", required = false) String authHeader, + @PathVariable String id) { + String userId = resolveUserId(userIdHeader, authHeader); + log.info("[Targeting] Retry campaign {}", id); + return ResponseEntity.ok(campaignService.retryCampaign(id, userId)); + } + + // ── Обновление бюджета и аудитории ─────────────────────────────────────── + + @PutMapping("/campaigns/{id}/budget") + public ResponseEntity updateBudget( + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "Authorization", required = false) String authHeader, + @PathVariable String id, + @RequestBody Map body) { + String userId = resolveUserId(userIdHeader, authHeader); + log.info("[Targeting] Update budget for campaign {}", id); + return ResponseEntity.ok(campaignService.updateBudget(id, userId, + body.get("totalBudget"), body.get("dailyBudget"))); + } + + @PutMapping("/campaigns/{id}/audience") + public ResponseEntity updateAudience( + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "Authorization", required = false) String authHeader, + @PathVariable String id, + @RequestBody TargetingAudienceProfile audience) { + String userId = resolveUserId(userIdHeader, authHeader); + log.info("[Targeting] Update audience for campaign {}", id); + return ResponseEntity.ok(campaignService.updateAudience(id, userId, audience)); + } + + // ── AdSets ─────────────────────────────────────────────────────────────── + + @GetMapping("/campaigns/{id}/adsets") + public ResponseEntity> getAdSets( + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "Authorization", required = false) String authHeader, + @PathVariable String id) { + String userId = resolveUserId(userIdHeader, authHeader); + return ResponseEntity.ok(campaignService.getAdSets(id, userId)); + } + + @PutMapping("/campaigns/{id}/adsets/{adSetId}") + public ResponseEntity updateAdSet( + @RequestHeader(value = "X-User-Id", required = false) String userIdHeader, + @RequestHeader(value = "Authorization", required = false) String authHeader, + @PathVariable String id, + @PathVariable String adSetId, + @RequestBody TargetingAdSet adSet) { + String userId = resolveUserId(userIdHeader, authHeader); + log.info("[Targeting] Update adSet {} for campaign {}", adSetId, id); + return ResponseEntity.ok(campaignService.updateAdSet(id, adSetId, userId, adSet)); + } } diff --git a/src/main/java/kz/konturai/parser/dto/targeting/TargetingRecommendationDto.java b/src/main/java/kz/konturai/parser/dto/targeting/TargetingRecommendationDto.java index 8428544..a6e3415 100644 --- a/src/main/java/kz/konturai/parser/dto/targeting/TargetingRecommendationDto.java +++ b/src/main/java/kz/konturai/parser/dto/targeting/TargetingRecommendationDto.java @@ -61,7 +61,7 @@ public class TargetingRecommendationDto { // ── Ключевые выводы из опросника ────────────────────────────────────────── /** Сигналы из опросника, которые повлияли на рекомендацию */ - private List questionnaireSinals; + private List questionnaireSignals; // ── Конкурентный контекст ───────────────────────────────────────────────── private String competitiveContext; diff --git a/src/main/java/kz/konturai/parser/enums/CampaignStatusMessage.java b/src/main/java/kz/konturai/parser/enums/CampaignStatusMessage.java new file mode 100644 index 0000000..65c28ba --- /dev/null +++ b/src/main/java/kz/konturai/parser/enums/CampaignStatusMessage.java @@ -0,0 +1,23 @@ +package kz.konturai.parser.enums; + +public enum CampaignStatusMessage { + + CREATED("Кампания создана. Начинается настройка таргетинга."), + ORCHESTRATING("ИИ анализирует стратегию и формирует сегменты аудитории."), + MAPPING("Генерация рекламных наборов и креативов."), + PUBLISHING("Публикация кампании в рекламные кабинеты Meta и TikTok."), + ACTIVE("Кампания успешно запущена и активна."), + PAUSED("Кампания приостановлена."), + COMPLETED("Кампания завершена."), + FAILED("Ошибка при публикации кампании."); + + private final String message; + + CampaignStatusMessage(String message) { + this.message = message; + } + + public String getMessage() { + return message; + } +} diff --git a/src/main/java/kz/konturai/parser/exception/ClaudeApiException.java b/src/main/java/kz/konturai/parser/exception/ClaudeApiException.java new file mode 100644 index 0000000..a38202d --- /dev/null +++ b/src/main/java/kz/konturai/parser/exception/ClaudeApiException.java @@ -0,0 +1,10 @@ +package kz.konturai.parser.exception; + +public class ClaudeApiException extends RuntimeException { + public ClaudeApiException(String message) { + super(message); + } + public ClaudeApiException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/kz/konturai/parser/exception/ValidationException.java b/src/main/java/kz/konturai/parser/exception/ValidationException.java new file mode 100644 index 0000000..0d1a47a --- /dev/null +++ b/src/main/java/kz/konturai/parser/exception/ValidationException.java @@ -0,0 +1,22 @@ +package kz.konturai.parser.exception; + +import lombok.Getter; + +@Getter +public class ValidationException extends RuntimeException { + + private final String field; + private final Object value; + + public ValidationException(String field, Object value, String message) { + super(message); + this.field = field; + this.value = value; + } + + public ValidationException(String message) { + super(message); + this.field = null; + this.value = null; + } +} diff --git a/src/main/java/kz/konturai/parser/model/CampaignPrediction.java b/src/main/java/kz/konturai/parser/model/CampaignPrediction.java new file mode 100644 index 0000000..e772b2d --- /dev/null +++ b/src/main/java/kz/konturai/parser/model/CampaignPrediction.java @@ -0,0 +1,151 @@ +package kz.konturai.parser.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * AI-предсказание эффективности рекламной кампании. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Document(collection = "campaign_predictions") +public class CampaignPrediction { + + @Id + private String id; + + @Indexed + @Field("campaign_id") + private String campaignId; + + @Field("created_at") + private LocalDateTime createdAt; + + @Field("is_latest") + private boolean isLatest; + + @Field("model_version") + @Builder.Default + private String modelVersion = "claude-sonnet-4-20250514"; + + // ── Основные показатели ────────────────────────────────────────────── + @Field("prediction_score") + private Integer predictionScore; // 0-100 + + @Field("prediction_label") + private String predictionLabel; // Отличная / Хорошая / Средняя / Слабая + + @Field("confidence") + private Integer confidence; // 0-100 + + // ── Прогнозируемые метрики ─────────────────────────────────────────── + @Field("predicted_metrics") + private Map predictedMetrics; + + // ── Прогноз по неделям ─────────────────────────────────────────────── + @Field("weekly_forecast") + private List weeklyForecast; + + // ── Факторы и рекомендации ─────────────────────────────────────────── + @Field("strength_factors") + private List strengthFactors; + + @Field("risk_factors") + private List riskFactors; + + @Field("top_recommendations") + private List topRecommendations; + + // ── Аудитория и бюджет ─────────────────────────────────────────────── + @Field("audience_insights") + private AudienceInsights audienceInsights; + + @Field("budget_optimization") + private BudgetOptimizationInsights budgetOptimization; + + @Field("competitive_analysis") + private CompetitiveAnalysis competitiveAnalysis; + + // ── Вложенные классы ───────────────────────────────────────────────── + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class MetricRange { + private Double min; + private Double max; + private String unit; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class WeeklyForecast { + private Integer week; + private Double expectedSpend; + private Integer expectedLeads; + private Integer expectedReach; + private String phase; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Recommendation { + private String priority; // HIGH / MEDIUM / LOW + private String category; // CREATIVE / AUDIENCE / BUDGET / TIMING / PLATFORM + private String title; + private String description; + private String expectedImpact; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class AudienceInsights { + private String bestPerformingSegment; + private String recommendedExpansion; + private String exclusionAdvice; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class BudgetOptimizationInsights { + private String currentAllocation; + private String recommendedReallocation; + private String potentialCplReduction; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class CompetitiveAnalysis { + private String marketSaturation; // HIGH / MEDIUM / LOW + private Integer differentiationScore; // 0-100 + private List competitiveAdvantages; + } +} diff --git a/src/main/java/kz/konturai/parser/model/PerformanceMetrics.java b/src/main/java/kz/konturai/parser/model/PerformanceMetrics.java index 4f2c99b..253cb5f 100644 --- a/src/main/java/kz/konturai/parser/model/PerformanceMetrics.java +++ b/src/main/java/kz/konturai/parser/model/PerformanceMetrics.java @@ -1,11 +1,14 @@ package kz.konturai.parser.model; +import com.fasterxml.jackson.annotation.JsonInclude; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import org.springframework.data.mongodb.core.mapping.Field; +import java.time.LocalDateTime; + /** * Performance metrics for a targeting campaign. */ @@ -15,27 +18,75 @@ import org.springframework.data.mongodb.core.mapping.Field; @AllArgsConstructor public class PerformanceMetrics { + /** Флаг — есть ли реальные данные (false если кампания ещё не запущена) */ + @Field("data_available") + @Builder.Default + private boolean dataAvailable = false; + + /** Сообщение для фронта */ + @Field("message") + @Builder.Default + private String message = "Данные появятся через 24-48 часов после запуска"; + @Field("impressions") - private Long impressions; + @Builder.Default + private Long impressions = 0L; @Field("clicks") - private Long clicks; + @Builder.Default + private Long clicks = 0L; @Field("spend") - private Double spend; + @Builder.Default + private Double spend = 0.0; @Field("leads") - private Long leads; + @Builder.Default + private Long leads = 0L; + + @Field("reach") + @Builder.Default + private Long reach = 0L; + + @Field("conversions") + @Builder.Default + private Long conversions = 0L; @Field("ctr") - private Double ctr; + @Builder.Default + private Double ctr = 0.0; @Field("cpm") - private Double cpm; + @Builder.Default + private Double cpm = 0.0; @Field("cpc") - private Double cpc; + @Builder.Default + private Double cpc = 0.0; @Field("roas") - private Double roas; + @Builder.Default + private Double roas = 0.0; + + @Field("currency") + @Builder.Default + private String currency = "KZT"; + + @Field("last_updated") + private LocalDateTime lastUpdated; + + /** Стандартный пустой объект для новых кампаний */ + public static PerformanceMetrics empty() { + return PerformanceMetrics.builder() + .dataAvailable(false) + .message("Данные появятся через 24-48 часов после запуска") + .build(); + } + + public static PerformanceMetrics unavailable() { + return PerformanceMetrics.builder() + .dataAvailable(false) + .message("Сервис метрик временно недоступен, повторите позже") + .build(); + } } diff --git a/src/main/java/kz/konturai/parser/model/TargetingAd.java b/src/main/java/kz/konturai/parser/model/TargetingAd.java index 13efe4f..a56790e 100644 --- a/src/main/java/kz/konturai/parser/model/TargetingAd.java +++ b/src/main/java/kz/konturai/parser/model/TargetingAd.java @@ -46,5 +46,8 @@ public class TargetingAd { private String abVariant; // "A", "B", "C" @Field("performance_score") - private int performanceScore; // 0-100 + private Integer performanceScore; // 0-100, null если метрик ещё нет + + @Field("needs_destination_url") + private Boolean needsDestinationUrl; // true если destinationUrl не задан } diff --git a/src/main/java/kz/konturai/parser/model/TargetingCampaign.java b/src/main/java/kz/konturai/parser/model/TargetingCampaign.java index 10ca53f..ff111e7 100644 --- a/src/main/java/kz/konturai/parser/model/TargetingCampaign.java +++ b/src/main/java/kz/konturai/parser/model/TargetingCampaign.java @@ -85,4 +85,7 @@ public class TargetingCampaign { @Field("completed_at") private LocalDateTime completedAt; + + @Field("duration_days") + private Integer durationDays; } diff --git a/src/main/java/kz/konturai/parser/repository/CampaignPredictionRepository.java b/src/main/java/kz/konturai/parser/repository/CampaignPredictionRepository.java new file mode 100644 index 0000000..381b72a --- /dev/null +++ b/src/main/java/kz/konturai/parser/repository/CampaignPredictionRepository.java @@ -0,0 +1,15 @@ +package kz.konturai.parser.repository; + +import kz.konturai.parser.model.CampaignPrediction; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface CampaignPredictionRepository extends MongoRepository { + + Optional findByCampaignIdAndIsLatestTrue(String campaignId); + + void deleteAllByCampaignId(String campaignId); +} diff --git a/src/main/java/kz/konturai/parser/service/AiTargetingSystemService.java b/src/main/java/kz/konturai/parser/service/AiTargetingSystemService.java index 8761d5a..c9f741a 100644 --- a/src/main/java/kz/konturai/parser/service/AiTargetingSystemService.java +++ b/src/main/java/kz/konturai/parser/service/AiTargetingSystemService.java @@ -269,7 +269,7 @@ public class AiTargetingSystemService { "estimatedFrequency": "например 2 – 3 раза в неделю на человека", "estimatedConversions": "например 80 – 250 лидов", - "questionnaireSinals": [ + "questionnaireSignals": [ "Сигнал из опросника 1 — почему он важен для таргетинга", "Сигнал 2", "Сигнал 3", diff --git a/src/main/java/kz/konturai/parser/service/CampaignPredictionService.java b/src/main/java/kz/konturai/parser/service/CampaignPredictionService.java new file mode 100644 index 0000000..59fc224 --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/CampaignPredictionService.java @@ -0,0 +1,265 @@ +package kz.konturai.parser.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import kz.konturai.parser.exception.ClaudeApiException; +import kz.konturai.parser.model.CampaignPrediction; +import kz.konturai.parser.model.TargetingCampaign; +import kz.konturai.parser.repository.CampaignPredictionRepository; +import kz.konturai.parser.repository.TargetingCampaignRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class CampaignPredictionService { + + private final ClaudeApiService claudeApiService; + private final CampaignPredictionRepository predictionRepository; + private final TargetingCampaignRepository campaignRepository; + private final ObjectMapper objectMapper; + + private static final String SYSTEM_PROMPT = """ + Ты эксперт по performance-маркетингу с 15-летним опытом в Meta Ads и TikTok Ads + для рынка Казахстана и СНГ. + + На вход получаешь данные рекламной кампании. Твоя задача — + предсказать результаты кампании и дать конкретные рекомендации. + Отвечай ТОЛЬКО валидным JSON без markdown и без пояснений. + + Верни строго такую структуру: + { + "predictionScore": число от 0 до 100, + "predictionLabel": "Отличная" | "Хорошая" | "Средняя" | "Слабая", + "confidence": число от 0 до 100, + "predictedMetrics": { + "ctr": { "min": число, "max": число, "unit": "%" }, + "cpl": { "min": число, "max": число, "unit": "KZT" }, + "reach": { "min": число, "max": число, "unit": "человек" }, + "conversions": { "min": число, "max": число, "unit": "лидов" }, + "roas": { "min": число, "max": число, "unit": "x" } + }, + "weeklyForecast": [ + { "week": 1, "expectedSpend": число, "expectedLeads": число, "expectedReach": число, "phase": "название" }, + { "week": 2, "expectedSpend": число, "expectedLeads": число, "expectedReach": число, "phase": "название" }, + { "week": 3, "expectedSpend": число, "expectedLeads": число, "expectedReach": число, "phase": "название" }, + { "week": 4, "expectedSpend": число, "expectedLeads": число, "expectedReach": число, "phase": "название" } + ], + "strengthFactors": ["фактор 1", "фактор 2", "фактор 3"], + "riskFactors": ["риск 1", "риск 2"], + "topRecommendations": [ + { + "priority": "HIGH", + "category": "CREATIVE", + "title": "заголовок", + "description": "детали", + "expectedImpact": "эффект" + } + ], + "audienceInsights": { + "bestPerformingSegment": "описание", + "recommendedExpansion": "куда расширить", + "exclusionAdvice": "кого исключить" + }, + "budgetOptimization": { + "currentAllocation": "описание", + "recommendedReallocation": "куда перераспределить", + "potentialCplReduction": "на сколько %" + }, + "competitiveAnalysis": { + "marketSaturation": "HIGH" | "MEDIUM" | "LOW", + "differentiationScore": число от 0 до 100, + "competitiveAdvantages": ["преимущество 1", "преимущество 2"] + } + } + """; + + /** + * Генерирует предсказание для кампании и сохраняет в MongoDB. + */ + public CampaignPrediction generateAndSave(TargetingCampaign campaign) { + log.info("[Prediction] Генерация предсказания для кампании {}", campaign.getId()); + + String userMessage = buildUserMessage(campaign); + + try { + String rawJson = claudeApiService.call(SYSTEM_PROMPT, userMessage, 4000); + CampaignPrediction prediction = parsePrediction(rawJson, campaign.getId()); + + // Снимаем флаг isLatest со старых предсказаний + predictionRepository.findByCampaignIdAndIsLatestTrue(campaign.getId()) + .ifPresent(old -> { + old.setLatest(false); + predictionRepository.save(old); + }); + + predictionRepository.save(prediction); + log.info("[Prediction] Предсказание сохранено. Score: {}, Label: {}", + prediction.getPredictionScore(), prediction.getPredictionLabel()); + return prediction; + + } catch (ClaudeApiException e) { + log.error("[Prediction] Ошибка Claude API: {}", e.getMessage()); + throw e; + } + } + + @Async("targetingExecutor") + public void generateAsync(TargetingCampaign campaign) { + try { + generateAndSave(campaign); + } catch (Exception e) { + log.error("[Prediction] Ошибка асинхронной генерации для {}: {}", campaign.getId(), e.getMessage()); + } + } + + public Optional getLatest(String campaignId) { + return predictionRepository.findByCampaignIdAndIsLatestTrue(campaignId); + } + + /** Пересчёт предсказаний каждый день в 06:00 для всех активных кампаний */ + @Scheduled(cron = "0 0 6 * * *") + public void scheduledRecalculation() { + log.info("[Prediction] Запуск планового пересчёта предсказаний"); + List active = campaignRepository.findAllByStatusAndUpdatedAtBefore( + "active", LocalDateTime.now()); + for (TargetingCampaign campaign : active) { + try { + generateAndSave(campaign); + } catch (Exception e) { + log.error("[Prediction] Ошибка пересчёта для {}: {}", campaign.getId(), e.getMessage()); + } + } + } + + private String buildUserMessage(TargetingCampaign campaign) { + StringBuilder sb = new StringBuilder(); + sb.append("ДАННЫЕ КАМПАНИИ:\n"); + + if (campaign.getAiRecommendations() != null) { + sb.append("Ниша: ").append(campaign.getAiRecommendations().getBusinessNiche()).append("\n"); + sb.append("Продукт: ").append(campaign.getAiRecommendations().getProductName()).append("\n"); + sb.append("Цель: ").append(campaign.getAiRecommendations().getGoal()).append("\n"); + sb.append("Тип таргетинга: ").append(campaign.getAiRecommendations().getRecommendedTargetingTypeLabel()).append("\n"); + } + + if (campaign.getBudget() != null) { + sb.append("Бюджет: ").append(campaign.getBudget().getTotalBudget()) + .append(" ").append(campaign.getBudget().getCurrency()).append("\n"); + sb.append("Дневной бюджет: ").append(campaign.getBudget().getDailyBudget()).append("\n"); + sb.append("Дата начала: ").append(campaign.getBudget().getStartDate()).append("\n"); + sb.append("Дата окончания: ").append(campaign.getBudget().getEndDate()).append("\n"); + } + + if (campaign.getPlatforms() != null) { + sb.append("Платформы: ").append(campaign.getPlatforms()).append("\n"); + } + + if (campaign.getAudience() != null) { + sb.append("Аудитория: возраст ").append(campaign.getAudience().getAgeMin()) + .append("-").append(campaign.getAudience().getAgeMax()).append("\n"); + sb.append("Интересы: ").append(campaign.getAudience().getInterests()).append("\n"); + sb.append("Гео: ").append(campaign.getAudience().getLocations()).append("\n"); + } + + if (campaign.getAiRecommendations() != null && + campaign.getAiRecommendations().getTargetingRecommendation() != null) { + var rec = campaign.getAiRecommendations().getTargetingRecommendation(); + sb.append("Фазы стратегии: ").append(rec.getPhases()).append("\n"); + sb.append("Конкурентный контекст: ").append(rec.getCompetitiveContext()).append("\n"); + sb.append("CII: ").append(rec.getCiiLevel()).append("\n"); + } + + if (campaign.getPerformanceMetrics() != null && campaign.getPerformanceMetrics().isDataAvailable()) { + var m = campaign.getPerformanceMetrics(); + sb.append("ТЕКУЩИЕ МЕТРИКИ:\n"); + sb.append("CTR: ").append(m.getCtr()).append("%\n"); + sb.append("CPC: ").append(m.getCpc()).append(" KZT\n"); + sb.append("Охват: ").append(m.getReach()).append("\n"); + sb.append("Лиды: ").append(m.getLeads()).append("\n"); + sb.append("Потрачено: ").append(m.getSpend()).append(" ").append(m.getCurrency()).append("\n"); + } + + sb.append("Длительность кампании: ").append(campaign.getDurationDays()).append(" дней\n"); + sb.append("Статус: ").append(campaign.getStatus()).append("\n"); + + return sb.toString(); + } + + private CampaignPrediction parsePrediction(String rawJson, String campaignId) { + try { + // Убираем markdown-обёртку + String json = rawJson.trim(); + if (json.startsWith("```")) { + int start = json.indexOf('\n'); + int end = json.lastIndexOf("```"); + if (start >= 0 && end > start) json = json.substring(start + 1, end).trim(); + } + + JsonNode root = objectMapper.readTree(json); + + // Парсим predictedMetrics + Map metrics = null; + if (root.has("predictedMetrics")) { + metrics = objectMapper.convertValue(root.get("predictedMetrics"), + new TypeReference<>() {}); + } + + // Парсим weeklyForecast + List forecast = null; + if (root.has("weeklyForecast")) { + forecast = objectMapper.convertValue(root.get("weeklyForecast"), + new TypeReference<>() {}); + } + + // Парсим topRecommendations + List recs = null; + if (root.has("topRecommendations")) { + recs = objectMapper.convertValue(root.get("topRecommendations"), + new TypeReference<>() {}); + } + + return CampaignPrediction.builder() + .campaignId(campaignId) + .createdAt(LocalDateTime.now()) + .isLatest(true) + .predictionScore(root.path("predictionScore").asInt(0)) + .predictionLabel(root.path("predictionLabel").asText("Средняя")) + .confidence(root.path("confidence").asInt(50)) + .predictedMetrics(metrics) + .weeklyForecast(forecast) + .strengthFactors(objectMapper.convertValue(root.path("strengthFactors"), new TypeReference<>() {})) + .riskFactors(objectMapper.convertValue(root.path("riskFactors"), new TypeReference<>() {})) + .topRecommendations(recs) + .audienceInsights(objectMapper.convertValue(root.path("audienceInsights"), + CampaignPrediction.AudienceInsights.class)) + .budgetOptimization(objectMapper.convertValue(root.path("budgetOptimization"), + CampaignPrediction.BudgetOptimizationInsights.class)) + .competitiveAnalysis(objectMapper.convertValue(root.path("competitiveAnalysis"), + CampaignPrediction.CompetitiveAnalysis.class)) + .build(); + + } catch (Exception e) { + log.error("[Prediction] Ошибка парсинга ответа Claude: {}", e.getMessage()); + // Возвращаем минимальный объект чтобы не потерять кампанию + return CampaignPrediction.builder() + .campaignId(campaignId) + .createdAt(LocalDateTime.now()) + .isLatest(true) + .predictionScore(50) + .predictionLabel("Средняя") + .confidence(30) + .build(); + } + } +} diff --git a/src/main/java/kz/konturai/parser/service/ClaudeApiService.java b/src/main/java/kz/konturai/parser/service/ClaudeApiService.java new file mode 100644 index 0000000..7902290 --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/ClaudeApiService.java @@ -0,0 +1,131 @@ +package kz.konturai.parser.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import kz.konturai.parser.exception.ClaudeApiException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.util.retry.Retry; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +/** + * Сервис для вызовов Anthropic Claude API. + * Retry: 3 попытки с exponential backoff (1s, 2s, 4s). + * Timeout: 60 секунд. + */ +@Service +@Slf4j +public class ClaudeApiService { + + private static final String CLAUDE_MODEL = "claude-sonnet-4-20250514"; + private static final int MAX_RETRIES = 3; + private static final Duration TIMEOUT = Duration.ofSeconds(60); + + private final WebClient webClient; + private final ObjectMapper objectMapper; + private final String apiKey; + + public ClaudeApiService( + @Value("${claude.api.key:}") String apiKey, + ObjectMapper objectMapper) { + this.apiKey = apiKey; + this.objectMapper = objectMapper; + this.webClient = WebClient.builder() + .baseUrl("https://api.anthropic.com") + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .defaultHeader("x-api-key", apiKey) + .defaultHeader("anthropic-version", "2023-06-01") + .build(); + } + + /** + * Отправляет запрос к Claude API и возвращает текст ответа. + * + * @param systemPrompt системный промпт + * @param userMessage пользовательское сообщение + * @param maxTokens максимальное количество токенов + * @return текст ответа + */ + public String call(String systemPrompt, String userMessage, int maxTokens) { + if (apiKey == null || apiKey.isBlank()) { + throw new ClaudeApiException("Claude API key не настроен. Установите claude.api.key в application.properties"); + } + + String preview = userMessage.length() > 200 ? userMessage.substring(0, 200) + "..." : userMessage; + log.info("[Claude API] Запрос отправлен. Длина промпта: {} симв. Превью: {}", userMessage.length(), preview); + + long startTime = System.currentTimeMillis(); + + try { + Map body = Map.of( + "model", CLAUDE_MODEL, + "max_tokens", maxTokens, + "system", systemPrompt, + "messages", List.of(Map.of("role", "user", "content", userMessage)) + ); + + String response = webClient.post() + .uri("/v1/messages") + .bodyValue(body) + .retrieve() + .bodyToMono(String.class) + .timeout(TIMEOUT) + .retryWhen(Retry.backoff(MAX_RETRIES, Duration.ofSeconds(1)) + .maxBackoff(Duration.ofSeconds(4)) + .doBeforeRetry(signal -> + log.warn("[Claude API] Повторная попытка #{}", signal.totalRetries() + 1))) + .block(); + + long elapsed = System.currentTimeMillis() - startTime; + + JsonNode root = objectMapper.readTree(response); + String text = root.path("content").get(0).path("text").asText(); + + log.info("[Claude API] Успешный ответ за {} мс. Длина ответа: {} симв.", elapsed, text.length()); + return text; + + } catch (ClaudeApiException e) { + throw e; + } catch (Exception e) { + long elapsed = System.currentTimeMillis() - startTime; + log.error("[Claude API] Ошибка за {} мс: {}", elapsed, e.getMessage()); + throw new ClaudeApiException("Ошибка вызова Claude API: " + e.getMessage(), e); + } + } + + /** + * Парсит JSON-ответ от Claude в объект указанного класса. + */ + public T callAndParse(String systemPrompt, String userMessage, int maxTokens, Class clazz) { + String raw = call(systemPrompt, userMessage, maxTokens); + try { + // Claude может обернуть JSON в ```json ... ``` + String json = extractJson(raw); + return objectMapper.readValue(json, clazz); + } catch (Exception e) { + log.error("[Claude API] Ошибка парсинга ответа в {}: {}", clazz.getSimpleName(), e.getMessage()); + throw new ClaudeApiException("Ошибка парсинга ответа Claude: " + e.getMessage(), e); + } + } + + private String extractJson(String raw) { + if (raw == null) return "{}"; + String trimmed = raw.trim(); + // Убираем markdown ```json ... ``` + if (trimmed.startsWith("```")) { + int start = trimmed.indexOf('\n'); + int end = trimmed.lastIndexOf("```"); + if (start >= 0 && end > start) { + return trimmed.substring(start + 1, end).trim(); + } + } + return trimmed; + } +} diff --git a/src/main/java/kz/konturai/parser/service/TargetingCampaignService.java b/src/main/java/kz/konturai/parser/service/TargetingCampaignService.java index 9827da2..c8e2fcc 100644 --- a/src/main/java/kz/konturai/parser/service/TargetingCampaignService.java +++ b/src/main/java/kz/konturai/parser/service/TargetingCampaignService.java @@ -6,13 +6,16 @@ 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.enums.CampaignStatusMessage; import kz.konturai.parser.exception.TargetingCampaignNotFoundException; +import kz.konturai.parser.exception.ValidationException; import kz.konturai.parser.model.*; import kz.konturai.parser.repository.MarketingAnalysisV3Repository; import kz.konturai.parser.repository.MarketingStrategyRepository; import kz.konturai.parser.repository.TargetingCampaignRepository; import kz.konturai.parser.dto.targeting.TargetingOrchestrationDto; import kz.konturai.parser.enums.CampaignObjective; +import kz.konturai.parser.util.RangeParser; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -27,7 +30,6 @@ 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; @@ -42,11 +44,10 @@ public class TargetingCampaignService { private final AiTargetologistService aiService; private final AiTargetingSystemService aiTargetingSystemService; private final ABTestingService abTestingService; -// private final FacebookAdsService facebookAdsService; -// private final TikTokAdsService tikTokAdsService; private final SocialMediaCredentialsService credentialsService; private final TargetingInsightService insightService; private final MarketingStrategyRepository strategyRepository; + private final CampaignPredictionService predictionService; public TargetingCampaign createCampaign(TargetingCampaignRequest req, String userId) { if (req.getStrategyId() == null || req.getStrategyId().isBlank()) { @@ -63,21 +64,38 @@ public class TargetingCampaignService { .orElseThrow(() -> new IllegalArgumentException("Analysis not found: " + strategy.getAnalysisId())); if (req.getTotalBudgetKzt() == null || req.getTotalBudgetKzt() <= 0) { - throw new IllegalArgumentException("Valid budget is required"); + throw new ValidationException("totalBudgetKzt", req.getTotalBudgetKzt(), "Бюджет должен быть больше 0"); } TargetingRecommendationDto recommendation = aiTargetingSystemService.generateLinkedRecommendation(strategy, analysis); + + // Вычисляем дату окончания из длительности стратегии + int durationDays = strategy.getDurationWeeks() != null + ? strategy.getDurationWeeks() * 7 + : 30; + LocalDateTime startDate = req.getStartDate() != null ? req.getStartDate() : LocalDateTime.now(); + LocalDateTime endDate = req.getEndDate() != null ? req.getEndDate() : startDate.plusDays(durationDays); + TargetingAudienceProfile audienceProfile = buildAudienceProfile(recommendation, req.getAudienceOverride()); + + // Заполняем estimatedReach из строки + if (recommendation != null && recommendation.getBudgetRecommendation() != null) { + String reach = recommendation.getBudgetRecommendation().getEstimatedDailyReach(); + audienceProfile.setEstimatedReachMin(RangeParser.extractMin(reach)); + audienceProfile.setEstimatedReachMax(RangeParser.extractMax(reach)); + } + TargetingAIAnalysisResult aiAnalysisResult = buildAiAnalysisResult(strategy, analysis, recommendation); BudgetConfig budget = BudgetConfig.builder() .totalBudget(req.getTotalBudgetKzt()) .dailyBudget(req.getDailyBudgetKzt()) - .startDate(req.getStartDate() != null ? req.getStartDate() : LocalDateTime.now()) - .endDate(req.getEndDate() != null ? req.getEndDate() : LocalDateTime.now().plusDays(30)) + .currency("KZT") + .startDate(startDate) + .endDate(endDate) .build(); TargetingCampaign campaign = TargetingCampaign.builder() @@ -90,17 +108,19 @@ public class TargetingCampaignService { .budget(budget) .audience(audienceProfile) .aiRecommendations(aiAnalysisResult) + .performanceMetrics(PerformanceMetrics.empty()) + .durationDays(durationDays) .createdAt(LocalDateTime.now()) .updatedAt(LocalDateTime.now()) .statusHistory(new ArrayList<>()) .insights(new ArrayList<>()) .externalIds(new HashMap<>()) .build(); - + // Map platforms campaign.setPlatforms(resolvePlatforms(req, recommendation)); - addStatusHistory(campaign, "CREATED", "Campaign draft created from strategy with AI targeting recommendation"); + addStatusHistory(campaign, "CREATED", CampaignStatusMessage.CREATED.getMessage()); TargetingCampaign saved = repository.save(campaign); // Process Async @@ -130,56 +150,84 @@ public class TargetingCampaignService { List credentials = credentialsService.getUserCredentials(userId); - addStatusHistory(campaign, "ORCHESTRATING", "Running Antigravity AI Orchestrator to map strategy and text creatives to ad API payload"); - + addStatusHistory(campaign, "ORCHESTRATING", CampaignStatusMessage.ORCHESTRATING.getMessage()); + repository.save(campaign); + // 1. Run The Master AI Orchestrator Pipeline TargetingOrchestrationDto payload = aiService.orchestrateCampaign(campaign, analysis, strategy, credentials); - + // 2. Map Payload to Local Database Models - addStatusHistory(campaign, "MAPPING", "Saving generated ad sets and creatives locally"); - List savedAdSets = new ArrayList<>(); + addStatusHistory(campaign, "MAPPING", CampaignStatusMessage.MAPPING.getMessage()); + + List allAdSets = new ArrayList<>(); for (TargetingOrchestrationDto.CampaignDto apiCamp : payload.getCampaigns()) { - for (TargetingOrchestrationDto.AdSetDto apiAdSet : apiCamp.getAdsets()) { - TargetingAdSet adSet = TargetingAdSet.builder() - .adSetId(java.util.UUID.randomUUID().toString()) - .name(apiAdSet.getAdsetName()) - .budget(apiAdSet.getDailyBudgetUsd() != null ? apiAdSet.getDailyBudgetUsd().doubleValue() : 0.0) - .bidStrategy(apiAdSet.getBidStrategy()) - .optimizationGoal(apiAdSet.getOptimizationGoal()) - .status("DRAFT") - .build(); - - List ads = new ArrayList<>(); - if (apiAdSet.getAds() != null) { - for (TargetingOrchestrationDto.AdDto apiAd : apiAdSet.getAds()) { - TargetingAd ad = TargetingAd.builder() - .adId(java.util.UUID.randomUUID().toString()) - .name(apiAd.getAdName()) - .headline(apiAd.getCreativePayload().getHeadlineId()) - .primaryText(apiAd.getCreativePayload().getBodyTextId()) - .callToAction(apiAd.getCreativePayload().getCallToAction()) - .mediaUrl(apiAd.getCreativePayload().getAssetId()) - .build(); - ads.add(ad); - } - } - adSet.setAds(ads); - savedAdSets.add(adSet); - } + if (apiCamp.getAdsets() != null) allAdSets.addAll(apiCamp.getAdsets()); } + + List savedAdSets = new ArrayList<>(); + double totalBudget = campaign.getBudget() != null ? campaign.getBudget().getTotalBudget() : 0.0; + List budgetSlices = distributeBudgetSlices(totalBudget, allAdSets.size()); + + for (int i = 0; i < allAdSets.size(); i++) { + TargetingOrchestrationDto.AdSetDto apiAdSet = allAdSets.get(i); + double adSetBudget = budgetSlices.get(i); + + List ads = new ArrayList<>(); + if (apiAdSet.getAds() != null) { + boolean hasAbTest = campaign.getAbTestConfig() != null; + for (int j = 0; j < apiAdSet.getAds().size(); j++) { + TargetingOrchestrationDto.AdDto apiAd = apiAdSet.getAds().get(j); + String mediaUrl = apiAd.getCreativePayload().getAssetId(); + String destUrl = apiAd.getCreativePayload().getDestinationUrl(); + + TargetingAd ad = TargetingAd.builder() + .adId(java.util.UUID.randomUUID().toString()) + .name(apiAd.getAdName()) + .adFormat(resolveAdFormat(mediaUrl, apiAdSet.getAds().size())) + .headline(apiAd.getCreativePayload().getHeadlineId()) + .primaryText(apiAd.getCreativePayload().getBodyTextId()) + .callToAction(apiAd.getCreativePayload().getCallToAction()) + .mediaUrl(mediaUrl) + .destinationUrl(destUrl != null && !destUrl.isBlank() ? destUrl : "#") + .needsDestinationUrl(destUrl == null || destUrl.isBlank()) + .abVariant(hasAbTest ? (j == 0 ? "A" : "B") : null) + .performanceScore(null) + .build(); + ads.add(ad); + } + } + + TargetingAdSet adSet = TargetingAdSet.builder() + .adSetId(java.util.UUID.randomUUID().toString()) + .name(apiAdSet.getAdsetName()) + .platform(resolvePlatformFromName(apiAdSet.getAdsetName(), campaign)) + .budget(adSetBudget) + .bidStrategy(apiAdSet.getBidStrategy()) + .optimizationGoal(apiAdSet.getOptimizationGoal()) + .placements(resolvePlacements(apiAdSet.getAdsetName(), campaign)) + .ads(ads) + .status("DRAFT") + .build(); + + savedAdSets.add(adSet); + } + campaign.setAdSets(savedAdSets); repository.save(campaign); // 3. External Platform Publishing - addStatusHistory(campaign, "PUBLISHING", "Pushing validated structure to Social Media Ads APIs"); + addStatusHistory(campaign, "PUBLISHING", CampaignStatusMessage.PUBLISHING.getMessage()); + repository.save(campaign); publishToPlatforms(campaign, userId); // 4. Finalize if ("failed".equals(campaign.getStatus())) { - addStatusHistory(campaign, "FAILED", "Some integrations failed or required credentials missing"); + addStatusHistory(campaign, "FAILED", CampaignStatusMessage.FAILED.getMessage()); } else { campaign.setStatus("active"); - addStatusHistory(campaign, "ACTIVE", "Antigravity Pipeline successfully compiled and pushed the campaign"); + addStatusHistory(campaign, "ACTIVE", CampaignStatusMessage.ACTIVE.getMessage()); + // Запускаем AI-предсказание асинхронно + predictionService.generateAsync(campaign); } campaign.setUpdatedAt(LocalDateTime.now()); @@ -188,7 +236,7 @@ public class TargetingCampaignService { } catch (Exception e) { log.error("[Targeting Pipeline] Error during async targeting pipeline", e); - failCampaign(campaign, "Pipeline error: " + e.getMessage()); + failCampaign(campaign, CampaignStatusMessage.FAILED.getMessage() + " Детали: " + e.getMessage()); } } @@ -281,7 +329,7 @@ public class TargetingCampaignService { // // Add TikTok pause when needed // } - addStatusHistory(campaign, "PAUSED", "Campaign manually paused"); + addStatusHistory(campaign, "PAUSED", CampaignStatusMessage.PAUSED.getMessage()); return repository.save(campaign); } @@ -297,7 +345,7 @@ public class TargetingCampaignService { // // Add TikTok resume when needed // } - addStatusHistory(campaign, "RESUMED", "Campaign manually resumed"); + addStatusHistory(campaign, "RESUMED", "Кампания возобновлена."); return repository.save(campaign); } @@ -341,7 +389,7 @@ public class TargetingCampaignService { private void failCampaign(TargetingCampaign campaign, String error) { campaign.setStatus("failed"); - addStatusHistory(campaign, "FAILED", error); + addStatusHistory(campaign, "FAILED", error != null ? error : CampaignStatusMessage.FAILED.getMessage()); campaign.setUpdatedAt(LocalDateTime.now()); campaign.setCompletedAt(LocalDateTime.now()); repository.save(campaign); @@ -457,7 +505,7 @@ public class TargetingCampaignService { .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()) + .questionnaireSignals(recommendation != null ? recommendation.getQuestionnaireSignals() : List.of()) .aiRecommendationsRationale(recommendation != null ? recommendation.getTypeRationale() : null) .quickWins(recommendation != null ? recommendation.getQuickWins() : List.of()) .warnings(recommendation != null ? recommendation.getWarnings() : List.of()) @@ -565,4 +613,127 @@ public class TargetingCampaignService { .distinct() .collect(Collectors.toList()); } + + // ── AdSet enrichment helpers ────────────────────────────────────────────── + + /** + * Определяет платформу AdSet'а по его названию или по платформам кампании. + */ + private String resolvePlatformFromName(String name, TargetingCampaign campaign) { + if (name == null) return "FACEBOOK"; + String n = name.toUpperCase(Locale.ROOT); + if (n.contains("TIKTOK") || n.contains("ТИК")) return "TIKTOK"; + if (n.contains("INSTAGRAM") || n.contains("INSTA")) return "INSTAGRAM"; + if (n.contains("FACEBOOK") || n.contains("FB")) return "FACEBOOK"; + // Fallback — первая платформа из кампании + if (campaign.getPlatforms() != null && !campaign.getPlatforms().isEmpty()) { + return campaign.getPlatforms().get(0).name(); + } + return "FACEBOOK"; + } + + /** + * Определяет placements на основе платформы. + */ + private List resolvePlacements(String adSetName, TargetingCampaign campaign) { + // Приоритет — placements из AI-рекомендаций + if (campaign.getAiRecommendations() != null + && campaign.getAiRecommendations().getTargetingRecommendation() != null) { + List all = campaign.getAiRecommendations() + .getTargetingRecommendation().getRecommendedPlacements(); + if (all != null && !all.isEmpty()) { + String platform = resolvePlatformFromName(adSetName, campaign); + return all.stream() + .filter(p -> p.toLowerCase(Locale.ROOT).contains(platform.toLowerCase(Locale.ROOT))) + .collect(Collectors.toList()); + } + } + String platform = resolvePlatformFromName(adSetName, campaign); + return switch (platform) { + case "INSTAGRAM" -> List.of("Instagram Feed", "Instagram Stories"); + case "TIKTOK" -> List.of("TikTok Feed", "TikTok TopView"); + default -> List.of("Facebook Feed", "Facebook Stories"); + }; + } + + /** + * Определяет adFormat по расширению mediaUrl и количеству объявлений. + */ + private String resolveAdFormat(String mediaUrl, int totalAdsInSet) { + if (totalAdsInSet > 1) return "CAROUSEL"; + if (mediaUrl == null) return "SINGLE_IMAGE"; + String lower = mediaUrl.toLowerCase(Locale.ROOT); + if (lower.endsWith(".mp4") || lower.endsWith(".mov")) return "VIDEO"; + return "SINGLE_IMAGE"; + } + + /** + * Распределяет бюджет между AdSet'ами по фазам. + * Фаза 1 → 30%, Фаза 2 → 40%, Фаза 3 → 30%. Остальные — равномерно. + */ + private List distributeBudgetSlices(double total, int count) { + if (count <= 0) return List.of(); + List slices = new ArrayList<>(); + if (count == 1) { + slices.add(total); + } else if (count == 2) { + slices.add(total * 0.40); + slices.add(total * 0.60); + } else if (count == 3) { + slices.add(total * 0.30); + slices.add(total * 0.40); + slices.add(total * 0.30); + } else { + double each = total / count; + for (int i = 0; i < count; i++) slices.add(each); + } + return slices; + } + + /** + * Извлекает фазы из aiRecommendations кампании. + */ + // ── Retry endpoint helper ───────────────────────────────────────────────── + + public TargetingCampaign retryCampaign(String campaignId, String userId) { + TargetingCampaign campaign = getCampaignById(campaignId, userId); + if (!"failed".equals(campaign.getStatus())) { + throw new ValidationException("status", campaign.getStatus(), + "Повторный запуск возможен только для кампаний со статусом FAILED"); + } + campaign.setStatus("draft"); + addStatusHistory(campaign, "CREATED", "Повторный запуск кампании после ошибки."); + TargetingCampaign saved = repository.save(campaign); + processAsync(saved.getId(), null, userId); + return saved; + } + + public TargetingCampaign updateBudget(String campaignId, String userId, Double totalBudget, Double dailyBudget) { + TargetingCampaign campaign = getCampaignById(campaignId, userId); + if (totalBudget != null && totalBudget > 0) campaign.getBudget().setTotalBudget(totalBudget); + if (dailyBudget != null && dailyBudget > 0) campaign.getBudget().setDailyBudget(dailyBudget); + campaign.setUpdatedAt(LocalDateTime.now()); + return repository.save(campaign); + } + + public TargetingCampaign updateAudience(String campaignId, String userId, TargetingAudienceProfile audience) { + TargetingCampaign campaign = getCampaignById(campaignId, userId); + campaign.setAudience(audience); + campaign.setUpdatedAt(LocalDateTime.now()); + return repository.save(campaign); + } + + public List getAdSets(String campaignId, String userId) { + TargetingCampaign campaign = getCampaignById(campaignId, userId); + return campaign.getAdSets() != null ? campaign.getAdSets() : List.of(); + } + + public TargetingCampaign updateAdSet(String campaignId, String adSetId, String userId, TargetingAdSet updated) { + TargetingCampaign campaign = getCampaignById(campaignId, userId); + if (campaign.getAdSets() != null) { + campaign.getAdSets().replaceAll(as -> as.getAdSetId().equals(adSetId) ? updated : as); + } + campaign.setUpdatedAt(LocalDateTime.now()); + return repository.save(campaign); + } } diff --git a/src/main/java/kz/konturai/parser/util/RangeParser.java b/src/main/java/kz/konturai/parser/util/RangeParser.java new file mode 100644 index 0000000..d07c752 --- /dev/null +++ b/src/main/java/kz/konturai/parser/util/RangeParser.java @@ -0,0 +1,59 @@ +package kz.konturai.parser.util; + +import lombok.experimental.UtilityClass; + +/** + * Утилита для парсинга диапазонов вида "10 000 – 30 000 человек/день". + */ +@UtilityClass +public class RangeParser { + + /** + * Извлекает минимальное значение из строки вида "10 000 – 30 000 человек/день". + * @return минимальное значение или null если парсинг не удался + */ + public Long extractMin(String rangeStr) { + if (rangeStr == null || rangeStr.isBlank()) return null; + long[] parsed = parse(rangeStr); + return parsed != null ? parsed[0] : null; + } + + /** + * Извлекает максимальное значение из строки вида "10 000 – 30 000 человек/день". + * @return максимальное значение или null если парсинг не удался + */ + public Long extractMax(String rangeStr) { + if (rangeStr == null || rangeStr.isBlank()) return null; + long[] parsed = parse(rangeStr); + return parsed != null ? parsed[1] : null; + } + + private long[] parse(String raw) { + try { + // Убираем всё кроме цифр, пробелов и разделителей + String cleaned = raw + .replaceAll("[^\\d\\s–—\\-]", "") // оставляем цифры, пробелы, тире + .replaceAll("\\s+", " ") // нормализуем пробелы + .trim(); + + // Ищем разделитель диапазона + String[] parts = cleaned.split("[–—\\-]", 2); + if (parts.length == 2) { + long min = parseLong(parts[0]); + long max = parseLong(parts[1]); + return new long[]{min, max}; + } + + // Одиночное число + long val = parseLong(cleaned); + return new long[]{val, val}; + + } catch (Exception e) { + return null; + } + } + + private long parseLong(String s) { + return Long.parseLong(s.replaceAll("\\s", "").trim()); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index da5f4bf..2f63ffa 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -106,6 +106,9 @@ deep-research.api.timeout=1800000 security.jwt.secret-base64=ZmFrZV9zZWNyZXRfMTIzNDU2Nzg5MGFiY2RlZmFrZV9zZWNyZXRfMTIzNDU2Nzg5MGFiY2Rl +# Claude (Anthropic) API — для AI предсказаний кампаний +claude.api.key=${CLAUDE_API_KEY:} + encryption.secret-key=ZmFrZV9zZWNyZXRfMTIzNDU2Nzg5MGFiY2RlZmFrZV9zZWNyZXRfMTIzNDU2Nzg5MGFiY2Rl posting.scheduler.enabled=true