target fix
This commit is contained in:
@@ -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<Map<String, Object>> handleValidationException(ValidationException e) {
|
||||
logger.warn("Ошибка валидации: field={}, value={}, message={}", e.getField(), e.getValue(), e.getMessage());
|
||||
Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> handleNotFound(TargetingCampaignNotFoundException e) {
|
||||
logger.warn("Кампания не найдена: {}", e.getMessage());
|
||||
return ResponseEntity.status(404).body(errorBody("NOT_FOUND", e.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalStateException.class)
|
||||
public ResponseEntity<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> errorBody(String code, String message) {
|
||||
|
||||
@@ -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<Map<String, Object>> 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<TargetingCampaign> 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<TargetingCampaign> updateBudget(
|
||||
@RequestHeader(value = "X-User-Id", required = false) String userIdHeader,
|
||||
@RequestHeader(value = "Authorization", required = false) String authHeader,
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, Double> 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<TargetingCampaign> 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<List<TargetingAdSet>> 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<TargetingCampaign> 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ public class TargetingRecommendationDto {
|
||||
|
||||
// ── Ключевые выводы из опросника ──────────────────────────────────────────
|
||||
/** Сигналы из опросника, которые повлияли на рекомендацию */
|
||||
private List<String> questionnaireSinals;
|
||||
private List<String> questionnaireSignals;
|
||||
|
||||
// ── Конкурентный контекст ─────────────────────────────────────────────────
|
||||
private String competitiveContext;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<String, MetricRange> predictedMetrics;
|
||||
|
||||
// ── Прогноз по неделям ───────────────────────────────────────────────
|
||||
@Field("weekly_forecast")
|
||||
private List<WeeklyForecast> weeklyForecast;
|
||||
|
||||
// ── Факторы и рекомендации ───────────────────────────────────────────
|
||||
@Field("strength_factors")
|
||||
private List<String> strengthFactors;
|
||||
|
||||
@Field("risk_factors")
|
||||
private List<String> riskFactors;
|
||||
|
||||
@Field("top_recommendations")
|
||||
private List<Recommendation> 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<String> competitiveAdvantages;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 не задан
|
||||
}
|
||||
|
||||
@@ -85,4 +85,7 @@ public class TargetingCampaign {
|
||||
|
||||
@Field("completed_at")
|
||||
private LocalDateTime completedAt;
|
||||
|
||||
@Field("duration_days")
|
||||
private Integer durationDays;
|
||||
}
|
||||
|
||||
@@ -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<CampaignPrediction, String> {
|
||||
|
||||
Optional<CampaignPrediction> findByCampaignIdAndIsLatestTrue(String campaignId);
|
||||
|
||||
void deleteAllByCampaignId(String campaignId);
|
||||
}
|
||||
@@ -269,7 +269,7 @@ public class AiTargetingSystemService {
|
||||
"estimatedFrequency": "например 2 – 3 раза в неделю на человека",
|
||||
"estimatedConversions": "например 80 – 250 лидов",
|
||||
|
||||
"questionnaireSinals": [
|
||||
"questionnaireSignals": [
|
||||
"Сигнал из опросника 1 — почему он важен для таргетинга",
|
||||
"Сигнал 2",
|
||||
"Сигнал 3",
|
||||
|
||||
@@ -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<CampaignPrediction> getLatest(String campaignId) {
|
||||
return predictionRepository.findByCampaignIdAndIsLatestTrue(campaignId);
|
||||
}
|
||||
|
||||
/** Пересчёт предсказаний каждый день в 06:00 для всех активных кампаний */
|
||||
@Scheduled(cron = "0 0 6 * * *")
|
||||
public void scheduledRecalculation() {
|
||||
log.info("[Prediction] Запуск планового пересчёта предсказаний");
|
||||
List<TargetingCampaign> 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<String, CampaignPrediction.MetricRange> metrics = null;
|
||||
if (root.has("predictedMetrics")) {
|
||||
metrics = objectMapper.convertValue(root.get("predictedMetrics"),
|
||||
new TypeReference<>() {});
|
||||
}
|
||||
|
||||
// Парсим weeklyForecast
|
||||
List<CampaignPrediction.WeeklyForecast> forecast = null;
|
||||
if (root.has("weeklyForecast")) {
|
||||
forecast = objectMapper.convertValue(root.get("weeklyForecast"),
|
||||
new TypeReference<>() {});
|
||||
}
|
||||
|
||||
// Парсим topRecommendations
|
||||
List<CampaignPrediction.Recommendation> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> 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> T callAndParse(String systemPrompt, String userMessage, int maxTokens, Class<T> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<SocialMediaCredentials> 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<TargetingAdSet> savedAdSets = new ArrayList<>();
|
||||
addStatusHistory(campaign, "MAPPING", CampaignStatusMessage.MAPPING.getMessage());
|
||||
|
||||
List<TargetingOrchestrationDto.AdSetDto> 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<TargetingAd> 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<TargetingAdSet> savedAdSets = new ArrayList<>();
|
||||
double totalBudget = campaign.getBudget() != null ? campaign.getBudget().getTotalBudget() : 0.0;
|
||||
List<Double> 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<TargetingAd> 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<String> resolvePlacements(String adSetName, TargetingCampaign campaign) {
|
||||
// Приоритет — placements из AI-рекомендаций
|
||||
if (campaign.getAiRecommendations() != null
|
||||
&& campaign.getAiRecommendations().getTargetingRecommendation() != null) {
|
||||
List<String> 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<Double> distributeBudgetSlices(double total, int count) {
|
||||
if (count <= 0) return List.of();
|
||||
List<Double> 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<TargetingAdSet> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user