target fix

This commit is contained in:
arys
2026-03-31 16:58:05 +05:00
parent e5cd104930
commit 2fad79e009
17 changed files with 1067 additions and 0 deletions
@@ -0,0 +1,120 @@
package kz.konturai.parser.controller;
import jakarta.validation.Valid;
import kz.konturai.parser.dto.targeting.AdCreativeDto;
import kz.konturai.parser.dto.targeting.CreativeRequest;
import kz.konturai.parser.dto.targeting.MarketingAnalysisDto;
import kz.konturai.parser.dto.targeting.MarketingStrategyDto;
import kz.konturai.parser.dto.targeting.StrategyRequest;
import kz.konturai.parser.dto.targeting.TargetingLaunchResultDto;
import kz.konturai.parser.dto.targeting.TopicRequest;
import kz.konturai.parser.service.AiTargetingSystemService;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Stateless AI Targeting System API.
*
* Flow: POST /analysis → POST /strategy → POST /creative
*
* No database involved — each endpoint takes the previous step's output
* as part of the request body and enriches it with AI-generated content.
*/
@RestController
@RequestMapping("/api/targeting")
@RequiredArgsConstructor
public class AiTargetingSystemController {
private static final Logger logger = LoggerFactory.getLogger(AiTargetingSystemController.class);
private final AiTargetingSystemService aiTargetingSystemService;
/**
* STEP 1 — Marketing Analysis.
*
* Accepts a business topic and returns a structured marketing analysis
* (target audience, platforms, budget, key messages, etc.).
*
* POST /api/targeting/analysis
* Body: { "topic": "детский сад в Алматы" }
*/
@PostMapping("/analysis")
public ResponseEntity<MarketingAnalysisDto> generateAnalysis(
@RequestBody @Valid TopicRequest request) {
logger.info("POST /api/targeting/analysis — topic: {}", request.getTopic());
MarketingAnalysisDto result = aiTargetingSystemService.generateAnalysis(request.getTopic());
return ResponseEntity.ok(result);
}
/**
* STEP 2 — Marketing Strategy.
*
* Accepts the full analysis DTO and returns a 3-phase 30-day campaign strategy
* with ad creatives brief and expected KPIs.
*
* POST /api/targeting/strategy
* Body: { "analysis": { ...MarketingAnalysisDto... } }
*/
@PostMapping("/strategy")
public ResponseEntity<MarketingStrategyDto> generateStrategy(
@RequestBody @Valid StrategyRequest request) {
logger.info("POST /api/targeting/strategy — topic: {}",
request.getAnalysis().getTopic());
MarketingStrategyDto result = aiTargetingSystemService.generateStrategy(request.getAnalysis());
return ResponseEntity.ok(result);
}
/**
* STEP 3 — Ad Creative.
*
* Accepts a creative brief (captionIdea + full analysis + full strategy)
* and returns a ready-to-launch ad post: headline, caption, hashtags,
* CTA button, and targeting settings.
*
* POST /api/targeting/creative
* Body: {
* "captionIdea": "string",
* "analysis": { ...MarketingAnalysisDto... },
* "strategy": { ...MarketingStrategyDto... }
* }
*/
@PostMapping("/creative")
public ResponseEntity<AdCreativeDto> generateCreative(
@RequestBody @Valid CreativeRequest request) {
logger.info("POST /api/targeting/creative — captionIdea: {}", request.getCaptionIdea());
AdCreativeDto result = aiTargetingSystemService.generateCreative(
request.getCaptionIdea(),
request.getAnalysis(),
request.getStrategy());
return ResponseEntity.ok(result);
}
/**
* STEP V3 — Launch Targeting from existing V3 Strategy.
*
* Берёт готовую V3-стратегию из MongoDB, генерирует настройки таргетинга
* через AI на основе V3-анализа и публикует первый пост из postCalendar в Facebook.
*
* Требования: стратегия должна иметь статус "completed" и непустой postCalendar.
*
* POST /api/targeting/launch/{strategyId}
*/
@PostMapping("/launch/{strategyId}")
public ResponseEntity<TargetingLaunchResultDto> launchFromStrategy(
@PathVariable String strategyId) {
logger.info("POST /api/targeting/launch/{} — launching targeting from V3 strategy", strategyId);
TargetingLaunchResultDto result = aiTargetingSystemService.launchTargetingFromStrategy(strategyId);
return ResponseEntity.ok(result);
}
}
@@ -0,0 +1,59 @@
package kz.konturai.parser.controller;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Collectors;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(IllegalStateException.class)
public ResponseEntity<Map<String, Object>> handleIllegalState(IllegalStateException e) {
logger.error("AI processing error: {}", e.getMessage());
return ResponseEntity.status(500).body(errorBody("AI_PROCESSING_ERROR", e.getMessage()));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, Object>> handleIllegalArgument(IllegalArgumentException e) {
logger.warn("Invalid argument: {}", e.getMessage());
return ResponseEntity.badRequest().body(errorBody("INVALID_ARGUMENT", e.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException e) {
Map<String, String> fieldErrors = e.getBindingResult().getFieldErrors()
.stream()
.collect(Collectors.toMap(
FieldError::getField,
fe -> fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "Некорректное значение",
(existing, duplicate) -> existing
));
Map<String, Object> body = new LinkedHashMap<>();
body.put("error", "VALIDATION_ERROR");
body.put("fields", fieldErrors);
return ResponseEntity.badRequest().body(body);
}
@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()));
}
private Map<String, Object> errorBody(String code, String message) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("error", code);
body.put("message", message);
return body;
}
}
@@ -0,0 +1,23 @@
package kz.konturai.parser.dto.targeting;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class AdCreativeDto {
private String headline;
private String caption;
private List<String> hashtags;
private String ctaButton;
private TargetingSettingsDto targetingSettings;
}
@@ -0,0 +1,23 @@
package kz.konturai.parser.dto.targeting;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class AdCreativeNeedDto {
private int id;
private String type;
private String theme;
private String captionIdea;
private String imageDescription;
private String cta;
private String targetSegment;
}
@@ -0,0 +1,19 @@
package kz.konturai.parser.dto.targeting;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class BudgetRecommendationDto {
private String minDaily;
private String optimalDaily;
private String monthly;
}
@@ -0,0 +1,21 @@
package kz.konturai.parser.dto.targeting;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class CreativeRequest {
@NotBlank(message = "Идея подписи не может быть пустой")
private String captionIdea;
@NotNull(message = "Данные анализа обязательны")
@Valid
private MarketingAnalysisDto analysis;
@NotNull(message = "Данные стратегии обязательны")
@Valid
private MarketingStrategyDto strategy;
}
@@ -0,0 +1,20 @@
package kz.konturai.parser.dto.targeting;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class ExpectedResultsDto {
private String reach;
private String clicks;
private String conversions;
private String cpl;
}
@@ -0,0 +1,28 @@
package kz.konturai.parser.dto.targeting;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class MarketingAnalysisDto {
private String topic;
private String marketOverview;
private TargetAudienceDto targetAudience;
private List<PlatformDto> platforms;
private List<String> bestAdFormats;
private List<String> postingTimes;
private BudgetRecommendationDto budgetRecommendation;
private List<String> keyMessages;
private String competitorsInsight;
private String recommendedVisuals;
}
@@ -0,0 +1,25 @@
package kz.konturai.parser.dto.targeting;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class MarketingStrategyDto {
private String strategyName;
private String goal;
private String duration;
private List<PhaseDto> phases;
private List<AdCreativeNeedDto> adCreativesNeeded;
private String totalBudget;
private ExpectedResultsDto expectedResults;
}
@@ -0,0 +1,24 @@
package kz.konturai.parser.dto.targeting;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class PhaseDto {
private int phase;
private String name;
private String duration;
private String objective;
private List<String> tactics;
private String kpi;
}
@@ -0,0 +1,19 @@
package kz.konturai.parser.dto.targeting;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class PlatformDto {
private String name;
private String priority;
private String reason;
}
@@ -0,0 +1,13 @@
package kz.konturai.parser.dto.targeting;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class StrategyRequest {
@NotNull(message = "Данные анализа обязательны")
@Valid
private MarketingAnalysisDto analysis;
}
@@ -0,0 +1,26 @@
package kz.konturai.parser.dto.targeting;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class TargetAudienceDto {
private String primary;
private String secondary;
private String ageRange;
private String gender;
private String income;
private List<String> interests;
private List<String> painPoints;
private List<String> motivations;
}
@@ -0,0 +1,41 @@
package kz.konturai.parser.dto.targeting;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TargetingLaunchResultDto {
// ── Источник ──────────────────────────────────────────────────────────────
private String strategyId;
private String analysisId;
private String scoringModelName;
private String scoringModelTitle;
// ── Первый опубликованный пост ────────────────────────────────────────────
private int postIndex;
private String platform;
private String contentType; // "фото" / "видео"
private String theme;
private String postText;
private List<String> hashtags;
private String imageUrl;
private boolean imagePosted;
// ── Facebook ──────────────────────────────────────────────────────────────
private String facebookPostId;
private String facebookStatus; // "PUBLISHED" | "FAILED" | "TEXT_ONLY"
// ── Таргетинг (генерируется AI из V3 анализа) ────────────────────────────
private TargetingSettingsDto targetingSettings;
// ── Сводка ───────────────────────────────────────────────────────────────
private String message;
}
@@ -0,0 +1,24 @@
package kz.konturai.parser.dto.targeting;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class TargetingSettingsDto {
private String audience;
private String age;
private List<String> interests;
private String placement;
private String budgetPerDay;
private String estimatedReach;
}
@@ -0,0 +1,13 @@
package kz.konturai.parser.dto.targeting;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
public class TopicRequest {
@NotBlank(message = "Тема не может быть пустой")
@Size(min = 3, max = 300, message = "Тема должна содержать от 3 до 300 символов")
private String topic;
}
@@ -0,0 +1,569 @@
package kz.konturai.parser.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import kz.konturai.parser.dto.MarketingAnalysisV3Request;
import kz.konturai.parser.dto.MarketingAnalysisV3Result;
import kz.konturai.parser.dto.targeting.AdCreativeDto;
import kz.konturai.parser.dto.targeting.MarketingAnalysisDto;
import kz.konturai.parser.dto.targeting.MarketingStrategyDto;
import kz.konturai.parser.dto.targeting.TargetingLaunchResultDto;
import kz.konturai.parser.dto.targeting.TargetingSettingsDto;
import kz.konturai.parser.model.MarketingAnalysisV3Document;
import kz.konturai.parser.model.MarketingStrategy;
import kz.konturai.parser.repository.MarketingAnalysisV3Repository;
import kz.konturai.parser.repository.MarketingStrategyRepository;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* AI Targeting System Service.
*
* Два режима работы:
* 1. STATELESS — generateAnalysis / generateStrategy / generateCreative
* Для React-приложения: принимает тему, возвращает структуры без БД.
*
* 2. V3-LINKED — launchTargetingFromStrategy(strategyId)
* Для полного цикла: берёт готовую V3-стратегию из MongoDB,
* генерирует настройки таргетинга из V3-анализа через AI
* и сразу публикует первый пост в Facebook.
*/
@Service
@RequiredArgsConstructor
public class AiTargetingSystemService {
private static final Logger logger = LoggerFactory.getLogger(AiTargetingSystemService.class);
// ── Существующие сервисы ──────────────────────────────────────────────────
private final OpenAIAnalyticsService openAIAnalyticsService;
private final ObjectMapper objectMapper;
private final MarketingStrategyRepository strategyRepository;
private final MarketingAnalysisV3Repository analysisV3Repository;
private final FacebookPostingService facebookPostingService;
private final MinIOService minIOService;
// ── Конфиг моделей (из application.properties targeting.ai.*) ────────────
@Value("${targeting.ai.audience-model:gpt-4o}")
private String analysisModel;
@Value("${targeting.ai.creatives-model:gpt-4o}")
private String strategyModel;
@Value("${targeting.ai.creatives-model:gpt-4o}")
private String creativeModel;
@Value("${targeting.ai.max-audience-tokens:2500}")
private int analysisMaxTokens;
@Value("${targeting.ai.max-creatives-tokens:2000}")
private int strategyMaxTokens;
@Value("${targeting.ai.max-creatives-tokens:2000}")
private int creativeMaxTokens;
// ═══════════════════════════════════════════════════════════════════════════
// V3-LINKED: Запуск таргетинга из существующей V3-стратегии
// ═══════════════════════════════════════════════════════════════════════════
/**
* Запускает таргетинг на основе готовой V3-стратегии:
* 1. Загружает {@link MarketingStrategy} по strategyId из MongoDB.
* 2. Загружает связанный {@link MarketingAnalysisV3Document}.
* 3. Берёт первый пост из postCalendar.
* 4. Генерирует настройки таргетинга через AI на основе V3-анализа.
* 5. Публикует пост в Facebook (с изображением если есть).
*
* @param strategyId ID стратегии из коллекции marketing_strategy
* @return результат запуска с Facebook postId и настройками таргетинга
*/
public TargetingLaunchResultDto launchTargetingFromStrategy(String strategyId) {
logger.info("Launching targeting from V3 strategy: {}", strategyId);
// 1. Загружаем стратегию
MarketingStrategy strategy = strategyRepository.findById(strategyId)
.orElseThrow(() -> new IllegalArgumentException(
"Стратегия не найдена: " + strategyId));
if (!"completed".equalsIgnoreCase(strategy.getStatus())) {
throw new IllegalStateException(
"Стратегия ещё не готова. Текущий статус: " + strategy.getStatus());
}
List<MarketingStrategy.PostCalendarItem> calendar = strategy.getPostCalendar();
if (calendar == null || calendar.isEmpty()) {
throw new IllegalStateException("Календарь постов пуст для стратегии: " + strategyId);
}
// 2. Загружаем анализ
MarketingAnalysisV3Document analysis = analysisV3Repository
.findById(strategy.getAnalysisId())
.orElseThrow(() -> new IllegalArgumentException(
"Анализ не найден: " + strategy.getAnalysisId()));
// 3. Берём первый пост из календаря
MarketingStrategy.PostCalendarItem firstPost = calendar.get(0);
logger.info("First post: platform={}, theme={}", firstPost.getPlatform(), firstPost.getTheme());
// 4. Генерируем настройки таргетинга через AI
TargetingSettingsDto targetingSettings = generateTargetingSettingsFromV3(analysis);
// 5. Публикуем в Facebook
String facebookPostId = null;
boolean imagePosted = false;
String facebookStatus;
try {
byte[] imageBytes = downloadPostImage(firstPost.getImageFilename());
if (imageBytes != null && imageBytes.length > 0) {
facebookPostId = facebookPostingService.postToPageWithImage(
null,
firstPost.getPostText(),
firstPost.getHashtags(),
imageBytes);
imagePosted = true;
facebookStatus = "PUBLISHED";
logger.info("Published to Facebook WITH image. Post ID: {}", facebookPostId);
} else {
facebookPostId = facebookPostingService.postToPage(
null,
firstPost.getPostText(),
firstPost.getHashtags());
facebookStatus = "TEXT_ONLY";
logger.info("Published to Facebook TEXT ONLY. Post ID: {}", facebookPostId);
}
} catch (Exception e) {
logger.error("Facebook posting failed for strategy {}: {}", strategyId, e.getMessage(), e);
facebookStatus = "FAILED";
}
return TargetingLaunchResultDto.builder()
.strategyId(strategyId)
.analysisId(strategy.getAnalysisId())
.scoringModelName(strategy.getScoringModelName())
.scoringModelTitle(strategy.getScoringModelTitle())
.postIndex(0)
.platform(firstPost.getPlatform())
.contentType(firstPost.getContentType())
.theme(firstPost.getTheme())
.postText(firstPost.getPostText())
.hashtags(firstPost.getHashtags())
.imageUrl(firstPost.getImageUrl())
.imagePosted(imagePosted)
.facebookPostId(facebookPostId)
.facebookStatus(facebookStatus)
.targetingSettings(targetingSettings)
.message(buildLaunchMessage(facebookStatus, facebookPostId))
.build();
}
/**
* Генерирует настройки таргетинга через AI из V3-анализа.
* Использует requestData (ниша, города, поведение) + resultData (CII, конкуренты)
* чтобы сформировать рекомендации по аудитории, бюджету и размещению.
*/
private TargetingSettingsDto generateTargetingSettingsFromV3(MarketingAnalysisV3Document analysis) {
MarketingAnalysisV3Request req = analysis.getRequestData();
MarketingAnalysisV3Result res = analysis.getResultData();
String systemPrompt = """
Ты — эксперт по платному трафику и таргетированной рекламе на рынке Казахстана.
На основе данных маркетингового анализа сформируй оптимальные настройки таргетинга.
Возвращай ТОЛЬКО валидный JSON без markdown, без пояснений, без блоков кода.
Чистый JSON-объект и ничего более. Весь текст — на русском языке.
""";
String instruction = String.format("""
Сформируй настройки таргетинга для Facebook/Instagram рекламы и верни строго следующий JSON:
{
"audience": "однострочное описание целевой аудитории",
"age": "возрастной диапазон, например 25-44",
"interests": ["интерес1", "интерес2", "интерес3", "интерес4"],
"placement": "Instagram Лента + Facebook Лента",
"budgetPerDay": "рекомендуемый дневной бюджет в KZT",
"estimatedReach": "оценочный охват в день, например 8 000 - 20 000 человек/день"
}
Данные для анализа:
Ниша: %s
Продукт: %s
Цель: %s
Города: %s
Тип клиентов: %s
Средний чек: %s
Статус SMM: %s
""",
safeGet(req != null ? req.getBusinessNiche() : null, "Не указано"),
safeGet(req != null ? req.getProductName() : null, "Не указано"),
safeGet(req != null ? req.getGoal() : null, "Не указано"),
formatList(req != null ? req.getPromotionCities() : null),
safeGet(req != null && req.getClientTarget() != null
? req.getClientTarget().name() : null, "Не указано"),
safeGet(req != null && req.getAverageCheck() != null
? req.getAverageCheck().name() : null, "Не указано"),
safeGet(req != null && req.getSmmStatus() != null
? req.getSmmStatus().name() : null, "Не указано")
);
// Обогащаем контекст данными из resultData если доступны
String context = buildV3AnalysisContext(res);
String rawResponse = openAIAnalyticsService.generateWithInstructionWithModel(
context, instruction, "ru", creativeModel, systemPrompt, 800, 60000L);
if (rawResponse == null) {
logger.warn("AI did not return targeting settings, using defaults");
return buildDefaultTargetingSettings(req);
}
try {
String json = extractJson(rawResponse);
return objectMapper.readValue(json, TargetingSettingsDto.class);
} catch (Exception e) {
logger.warn("Failed to parse targeting settings from AI, using defaults. Error: {}", e.getMessage());
return buildDefaultTargetingSettings(req);
}
}
/**
* Формирует читаемый контекст из resultData V3-анализа для AI-промпта.
*/
private String buildV3AnalysisContext(MarketingAnalysisV3Result res) {
if (res == null) return "";
StringBuilder ctx = new StringBuilder();
if (res.getExecutiveSummary() != null) {
MarketingAnalysisV3Result.ExecutiveSummary es = res.getExecutiveSummary();
ctx.append("Уровень конкуренции: ").append(safeGet(es.getCompetitionLevel(), "")).append("\n");
ctx.append("Тренд спроса: ").append(safeGet(es.getDemandTrend(), "")).append("\n");
}
if (res.getCompetitionIntensity() != null) {
ctx.append("Индекс конкурентности (CII): ")
.append(res.getCompetitionIntensity().getCiiIndex()).append("/100\n");
}
if (res.getContentProfile() != null && res.getContentProfile().getTopHashtags() != null) {
ctx.append("Топ хештеги конкурентов: ")
.append(String.join(", ", res.getContentProfile().getTopHashtags())).append("\n");
}
return ctx.toString();
}
/**
* Дефолтные настройки таргетинга если AI недоступен.
*/
private TargetingSettingsDto buildDefaultTargetingSettings(MarketingAnalysisV3Request req) {
String city = req != null && req.getPromotionCities() != null && !req.getPromotionCities().isEmpty()
? req.getPromotionCities().get(0) : "Алматы";
String niche = req != null ? safeGet(req.getBusinessNiche(), "бизнес") : "бизнес";
return TargetingSettingsDto.builder()
.audience("Жители " + city + ", интересующиеся " + niche)
.age("25-44")
.interests(List.of(niche, "Услуги", "Казахстан", "Качество"))
.placement("Instagram Лента + Facebook Лента")
.budgetPerDay("5 000 - 15 000 KZT")
.estimatedReach("5 000 - 20 000 человек/день")
.build();
}
/**
* Скачивает изображение из MinIO по имени файла.
* Возвращает null если файл не найден или имя пустое.
*/
private byte[] downloadPostImage(String imageFilename) {
if (imageFilename == null || imageFilename.isBlank()) {
return null;
}
try {
InputStream stream = minIOService.downloadFile(imageFilename);
if (stream == null) return null;
byte[] bytes = stream.readAllBytes();
stream.close();
logger.info("Downloaded image from MinIO: {} ({} bytes)", imageFilename, bytes.length);
return bytes;
} catch (Exception e) {
logger.warn("Could not download image '{}' from MinIO: {}", imageFilename, e.getMessage());
return null;
}
}
private String buildLaunchMessage(String facebookStatus, String postId) {
return switch (facebookStatus) {
case "PUBLISHED" -> "✅ Пост с изображением успешно опубликован в Facebook. ID: " + postId;
case "TEXT_ONLY" -> "✅ Текстовый пост опубликован в Facebook. ID: " + postId;
case "FAILED" -> "⚠️ Пост не удалось опубликовать в Facebook. Настройки таргетинга сохранены.";
default -> "Запуск таргетинга выполнен.";
};
}
// ═══════════════════════════════════════════════════════════════════════════
// STATELESS: Независимый режим для React-приложения
// ═══════════════════════════════════════════════════════════════════════════
public MarketingAnalysisDto generateAnalysis(String topic) {
logger.info("Generating marketing analysis for topic: {}", topic);
String systemPrompt = """
Ты — профессиональный маркетинговый аналитик с 15-летним опытом работы
на рынках СНГ (Казахстан, Россия, Узбекистан). Специализируешься на digital-рекламе.
Возвращай ТОЛЬКО валидный JSON без markdown, без пояснений, без блоков кода.
Чистый JSON-объект и ничего более. Весь текст внутри JSON — на русском языке.
""";
String instruction = """
Проанализируй данный бизнес для создания рекламной кампании и верни строго следующий JSON:
{
"topic": "string",
"marketOverview": "2-3 предложения о рынке на русском",
"targetAudience": {
"primary": "описание основной аудитории",
"secondary": "описание вторичной аудитории",
"ageRange": "например 25-45",
"gender": "например Женщины 70%, Мужчины 30%",
"income": "например Средний и выше среднего",
"interests": ["интерес1", "интерес2", "интерес3", "интерес4", "интерес5"],
"painPoints": ["боль1", "боль2", "боль3"],
"motivations": ["мотивация1", "мотивация2", "мотивация3"]
},
"platforms": [
{ "name": "Instagram", "priority": "Высокий", "reason": "почему на русском" },
{ "name": "Facebook", "priority": "Средний", "reason": "почему" },
{ "name": "TikTok", "priority": "Низкий", "reason": "почему" }
],
"bestAdFormats": ["Stories", "Reels", "Публикация", "Карусель"],
"postingTimes": ["Пн-Пт 18:00-21:00", "Сб-Вс 10:00-13:00"],
"budgetRecommendation": {
"minDaily": "5 000 KZT",
"optimalDaily": "15 000 KZT",
"monthly": "300 000 - 450 000 KZT"
},
"keyMessages": ["сообщение1", "сообщение2", "сообщение3"],
"competitorsInsight": "краткий анализ конкурентов на русском",
"recommendedVisuals": "подробное описание лучших визуалов на русском"
}
Бизнес для анализа:
""";
String rawResponse = openAIAnalyticsService.generateWithInstructionWithModel(
topic, instruction, "ru", analysisModel, systemPrompt, analysisMaxTokens, 120000L);
return parseResponse(rawResponse, MarketingAnalysisDto.class, "Marketing Analysis");
}
public MarketingStrategyDto generateStrategy(MarketingAnalysisDto analysis) {
logger.info("Generating marketing strategy for topic: {}", analysis.getTopic());
String systemPrompt = """
Ты — старший маркетинговый стратег, специалист по performance-маркетингу
на рынках СНГ (Казахстан). Создаёшь эффективные рекламные стратегии.
Возвращай ТОЛЬКО валидный JSON без markdown, без пояснений, без блоков кода.
Чистый JSON-объект и ничего более. Весь текст внутри JSON — на русском языке.
""";
String instruction = """
На основе предоставленного маркетингового анализа создай полную 30-дневную
рекламную стратегию и верни строго следующий JSON:
{
"strategyName": "название стратегии на русском",
"goal": "главная цель кампании на русском",
"duration": "30 дней",
"phases": [
{
"phase": 1,
"name": "Осведомлённость",
"duration": "10 дней",
"objective": "что достигаем",
"tactics": ["тактика1", "тактика2", "тактика3"],
"kpi": "ожидаемый KPI"
},
{
"phase": 2,
"name": "Вовлечённость",
"duration": "10 дней",
"objective": "...",
"tactics": ["..."],
"kpi": "..."
},
{
"phase": 3,
"name": "Конверсия",
"duration": "10 дней",
"objective": "...",
"tactics": ["..."],
"kpi": "..."
}
],
"adCreativesNeeded": [
{
"id": 1,
"type": "Публикация в ленте",
"theme": "тема поста",
"captionIdea": "идея подписи",
"imageDescription": "детальное описание изображения",
"cta": "Записаться →",
"targetSegment": "сегмент аудитории"
},
{
"id": 2,
"type": "Сторис",
"theme": "...",
"captionIdea": "...",
"imageDescription": "...",
"cta": "...",
"targetSegment": "..."
},
{
"id": 3,
"type": "Обложка Reels",
"theme": "...",
"captionIdea": "...",
"imageDescription": "...",
"cta": "...",
"targetSegment": "..."
}
],
"totalBudget": "450 000 KZT",
"expectedResults": {
"reach": "50 000 - 100 000 человек",
"clicks": "2 000 - 5 000",
"conversions": "100 - 300 лидов",
"cpl": "1 500 - 3 000 KZT"
}
}
Маркетинговый анализ:
""";
String rawResponse = openAIAnalyticsService.generateWithInstructionWithModel(
serializeToJson(analysis, "analysis"), instruction, "ru",
strategyModel, systemPrompt, strategyMaxTokens, 120000L);
return parseResponse(rawResponse, MarketingStrategyDto.class, "Marketing Strategy");
}
public AdCreativeDto generateCreative(
String captionIdea,
MarketingAnalysisDto analysis,
MarketingStrategyDto strategy) {
logger.info("Generating ad creative for idea: {}", captionIdea);
String systemPrompt = """
Ты — профессиональный копирайтер для рекламы в социальных сетях.
Специализируешься на рынке Казахстана, пишешь эмоционально и убедительно.
Твои тексты всегда включают хук, выгоды, социальное доказательство и призыв к действию.
Возвращай ТОЛЬКО валидный JSON без markdown, без пояснений, без блоков кода.
Чистый JSON-объект и ничего более. Весь текст внутри JSON — на русском языке.
""";
String instruction = """
На основе предоставленных данных создай рекламный пост и верни строго следующий JSON:
{
"headline": "короткий цепляющий заголовок до 10 слов на русском",
"caption": "полный текст поста 3-5 предложений с эмоджи на русском",
"hashtags": ["#тег1", "#тег2", "#тег3", "#тег4", "#тег5", "#тег6"],
"ctaButton": "Записаться →",
"targetingSettings": {
"audience": "однострочное описание аудитории на русском",
"age": "возрастной диапазон из анализа",
"interests": ["интерес1", "интерес2", "интерес3", "интерес4"],
"placement": "Instagram Лента + Facebook Лента",
"budgetPerDay": "бюджет из анализа в KZT",
"estimatedReach": "например 5 000 - 15 000 человек/день"
}
}
Данные для создания поста:
""";
String contextJson = buildCreativeContext(captionIdea, analysis, strategy);
String rawResponse = openAIAnalyticsService.generateWithInstructionWithModel(
contextJson, instruction, "ru", creativeModel, systemPrompt, creativeMaxTokens, 90000L);
return parseResponse(rawResponse, AdCreativeDto.class, "Ad Creative");
}
// ═══════════════════════════════════════════════════════════════════════════
// HELPERS
// ═══════════════════════════════════════════════════════════════════════════
private String extractJson(String rawResponse) {
if (rawResponse == null || rawResponse.isBlank()) {
throw new IllegalStateException("Пустой ответ от AI");
}
String trimmed = rawResponse.trim();
if (trimmed.startsWith("```")) {
int firstNewline = trimmed.indexOf('\n');
int lastBacktick = trimmed.lastIndexOf("```");
if (firstNewline > 0 && lastBacktick > firstNewline) {
trimmed = trimmed.substring(firstNewline + 1, lastBacktick).trim();
}
}
int start = trimmed.indexOf('{');
int end = trimmed.lastIndexOf('}');
if (start == -1 || end == -1 || end <= start) {
throw new IllegalStateException("JSON не найден в ответе AI: "
+ trimmed.substring(0, Math.min(200, trimmed.length())));
}
return trimmed.substring(start, end + 1);
}
private <T> T parseResponse(String rawResponse, Class<T> type, String context) {
if (rawResponse == null) {
throw new IllegalStateException("AI не вернул ответ для: " + context);
}
try {
return objectMapper.readValue(extractJson(rawResponse), type);
} catch (IllegalStateException e) {
throw e;
} catch (Exception e) {
logger.error("Failed to parse {} response. Raw: {}", context, rawResponse, e);
throw new IllegalStateException("Ошибка парсинга " + context + ": " + e.getMessage());
}
}
private String serializeToJson(Object obj, String context) {
try {
return objectMapper.writeValueAsString(obj);
} catch (Exception e) {
throw new IllegalStateException("Ошибка сериализации " + context + ": " + e.getMessage());
}
}
private String buildCreativeContext(String captionIdea, MarketingAnalysisDto analysis, MarketingStrategyDto strategy) {
try {
return objectMapper.createObjectNode()
.put("captionIdea", captionIdea)
.set("analysis", objectMapper.valueToTree(analysis))
.toString()
+ "\nСтратегия: " + objectMapper.writeValueAsString(strategy);
} catch (Exception e) {
return "Идея: " + captionIdea + "\nАнализ: " + serializeToJson(analysis, "analysis");
}
}
private String safeGet(String value, String fallback) {
return (value != null && !value.isBlank()) ? value : fallback;
}
private String formatList(List<String> list) {
if (list == null || list.isEmpty()) return "Не указано";
return String.join(", ", list);
}
}