target fix

This commit is contained in:
arys
2026-04-03 10:20:17 +05:00
parent ce1f265730
commit 630ddb9561
17 changed files with 1120 additions and 70 deletions
@@ -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