diff --git a/src/main/java/kz/konturai/parser/controller/FacebookLeadsController.java b/src/main/java/kz/konturai/parser/controller/FacebookLeadsController.java new file mode 100644 index 0000000..f1b9d11 --- /dev/null +++ b/src/main/java/kz/konturai/parser/controller/FacebookLeadsController.java @@ -0,0 +1,105 @@ +package kz.konturai.parser.controller; + +import kz.konturai.parser.config.FacebookLeadProperties; +import kz.konturai.parser.dto.ApiResponse; +import kz.konturai.parser.service.FacebookLeadCollectorService; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * Контроллер для ручного запуска скрапинг-парсера Facebook лидов. + */ +@RestController +@RequestMapping("/api/facebook/leads") +@RequiredArgsConstructor +public class FacebookLeadsController { + + private static final Logger log = LoggerFactory.getLogger(FacebookLeadsController.class); + + private final FacebookLeadCollectorService leadCollectorService; + private final FacebookLeadProperties leadProperties; + + /** + * Ручной запуск сбора горячих лидов из Facebook. + * + * POST /api/facebook/leads/scrape + */ + @PostMapping("/scrape") + public ResponseEntity>> scrapeLeads() { + log.info("Manual Facebook lead scraping triggered"); + + try { + if (!isConfigured()) { + return ResponseEntity.badRequest().body( + ApiResponse.error("Facebook не настроен: отсутствуют pageId или accessToken") + ); + } + + int newLeadsCount = leadCollectorService.collectHotLeads(); + + Map result = Map.of( + "newLeadsCount", newLeadsCount, + "pageId", leadProperties.getPageId(), + "triggerTime", LocalDateTime.now().toString(), + "triggerType", "manual" + ); + + String message = newLeadsCount > 0 + ? "Сбор лидов завершен. Найдено новых лидов: " + newLeadsCount + : "Сбор лидов завершен. Новых лидов не найдено"; + + return ResponseEntity.ok(ApiResponse.success(message, result)); + + } catch (Exception e) { + log.error("Manual Facebook lead scraping failed", e); + return ResponseEntity.internalServerError().body( + ApiResponse.error("Ошибка при сборе лидов: " + e.getMessage()) + ); + } + } + + /** + * Проверка статуса конфигурации Facebook. + * + * GET /api/facebook/leads/status + */ + @GetMapping("/status") + public ResponseEntity>> getStatus() { + try { + boolean configured = isConfigured(); + Map status = Map.of( + "configured", configured, + "pageId", configured ? leadProperties.getPageId() : null, + "accessTokenPresent", StringUtils.hasText(leadProperties.getAccessToken()), + "cronExpression", leadProperties.getCron(), + "graphApiVersion", leadProperties.getGraphApiVersion(), + "postsPageSize", leadProperties.getPostsPageSize(), + "commentsPageSize", leadProperties.getCommentsPageSize() + ); + + String message = configured + ? "Facebook настроен корректно" + : "Facebook не настроен: проверьте pageId и accessToken"; + + return ResponseEntity.ok(ApiResponse.success(message, status)); + + } catch (Exception e) { + log.error("Failed to get Facebook lead status", e); + return ResponseEntity.internalServerError().body( + ApiResponse.error("Ошибка при получении статуса: " + e.getMessage()) + ); + } + } + + private boolean isConfigured() { + return StringUtils.hasText(leadProperties.getPageId()) + && StringUtils.hasText(leadProperties.getAccessToken()); + } +} diff --git a/src/main/java/kz/konturai/parser/dto/MarketingStrategyResponse.java b/src/main/java/kz/konturai/parser/dto/MarketingStrategyResponse.java index b1932c9..9bdc5a3 100644 --- a/src/main/java/kz/konturai/parser/dto/MarketingStrategyResponse.java +++ b/src/main/java/kz/konturai/parser/dto/MarketingStrategyResponse.java @@ -174,6 +174,7 @@ public class MarketingStrategyResponse { public static class PostCalendarItem { private LocalDateTime publishDate; + private Integer weekNumber; private String platform; private String contentType; private String theme; @@ -184,13 +185,17 @@ public class MarketingStrategyResponse { private String imageFilename; private String taskId; private String taskStatus; + private String videoUrl; + private String videoFilename; public PostCalendarItem() { } - public PostCalendarItem(LocalDateTime publishDate, String platform, String contentType, String theme, + public PostCalendarItem(LocalDateTime publishDate, Integer weekNumber, String platform, String contentType, + String theme, String postText, List hashtags, String publishTime) { this.publishDate = publishDate; + this.weekNumber = weekNumber; this.platform = platform; this.contentType = contentType; this.theme = theme; @@ -207,6 +212,14 @@ public class MarketingStrategyResponse { this.publishDate = publishDate; } + public Integer getWeekNumber() { + return weekNumber; + } + + public void setWeekNumber(Integer weekNumber) { + this.weekNumber = weekNumber; + } + public String getPlatform() { return platform; } @@ -286,5 +299,21 @@ public class MarketingStrategyResponse { public void setTaskStatus(String taskStatus) { this.taskStatus = taskStatus; } + + public String getVideoUrl() { + return videoUrl; + } + + public void setVideoUrl(String videoUrl) { + this.videoUrl = videoUrl; + } + + public String getVideoFilename() { + return videoFilename; + } + + public void setVideoFilename(String videoFilename) { + this.videoFilename = videoFilename; + } } } diff --git a/src/main/java/kz/konturai/parser/model/MarketingStrategy.java b/src/main/java/kz/konturai/parser/model/MarketingStrategy.java index 8c9a298..d9445c4 100644 --- a/src/main/java/kz/konturai/parser/model/MarketingStrategy.java +++ b/src/main/java/kz/konturai/parser/model/MarketingStrategy.java @@ -96,6 +96,9 @@ public class MarketingStrategy { @Field("publish_date") private LocalDateTime publishDate; + @Field("week_number") + private Integer weekNumber; + @Field("platform") private String platform; diff --git a/src/main/java/kz/konturai/parser/service/MarketingStrategyV3Service.java b/src/main/java/kz/konturai/parser/service/MarketingStrategyV3Service.java index c6b8999..0410b6c 100644 --- a/src/main/java/kz/konturai/parser/service/MarketingStrategyV3Service.java +++ b/src/main/java/kz/konturai/parser/service/MarketingStrategyV3Service.java @@ -33,16 +33,17 @@ import java.util.stream.Collectors; @Slf4j public class MarketingStrategyV3Service { - private final MarketingStrategyRepository repository; - private final MarketingAnalysisV3Repository analysisRepository; - private final OpenAIAnalyticsService aiService; - private final ImageGenerationService imageGenerationService; - private final GeminiVideoGenerationService geminiVideoService; - private final PostingTaskService postingTaskService; - private final MinIOService minIOService; - private final FacebookPostingService facebookPostingService; + private final MarketingStrategyRepository repository; + private final MarketingAnalysisV3Repository analysisRepository; + private final OpenAIAnalyticsService aiService; + private final ImageGenerationService imageGenerationService; + private final GeminiVideoGenerationService geminiVideoService; + private final PostingTaskService postingTaskService; + private final MinIOService minIOService; + private final FacebookPostingService facebookPostingService; - @Autowired @Lazy + @Autowired + @Lazy private MarketingStrategyV3Service self; private final ObjectMapper objectMapper = new ObjectMapper() @@ -71,7 +72,7 @@ public class MarketingStrategyV3Service { } public MarketingStrategy generateStrategy(String analysisId, MarketingStrategyRequest request, - String userId, List referenceFilenames) { + String userId, List referenceFilenames) { MarketingAnalysisV3Document analysisDoc = analysisRepository.findById(analysisId) .orElseThrow(() -> new IllegalArgumentException("Analysis V3 not found: " + analysisId)); @@ -101,8 +102,8 @@ public class MarketingStrategyV3Service { @Async("reportGenerationExecutor") public void processStrategyGenerationAsync(String strategyId, - MarketingAnalysisV3Document analysis, - MarketingStrategy strategy) { + MarketingAnalysisV3Document analysis, + MarketingStrategy strategy) { try { strategy.setStatus("processing"); addStatusHistory(strategy, "processing", "Запускаю скоринговую систему..."); @@ -128,7 +129,7 @@ public class MarketingStrategyV3Service { repository.save(strategy); String systemPrompt = buildSystemPrompt(scoring, analysis); - String userPrompt = buildUserPrompt(analysis, scoring); + String userPrompt = buildUserPrompt(analysis, scoring); // Step 3: AI call String rawResponse = aiService.generateWithInstructionWithModel( @@ -136,26 +137,27 @@ public class MarketingStrategyV3Service { systemPrompt, 16000, 240_000L); String jsonResponse = extractCleanJson(rawResponse); - if (jsonResponse == null) throw new IllegalStateException("AI вернул невалидный JSON"); + if (jsonResponse == null) + throw new IllegalStateException("AI вернул невалидный JSON"); - Map strategyData = objectMapper.readValue(jsonResponse, new TypeReference<>() {}); + Map strategyData = objectMapper.readValue(jsonResponse, new TypeReference<>() { + }); if (strategy.getStrategyData() != null) { strategy.getStrategyData().forEach(strategyData::putIfAbsent); } - strategyData.put("scoringModel", scoring.getModel().name()); + strategyData.put("scoringModel", scoring.getModel().name()); strategyData.put("scoringModelTitle", scoring.getModel().getTitle()); - strategyData.put("scoringRationale", scoring.getRationale()); + strategyData.put("scoringRationale", scoring.getRationale()); strategyData.put("activeModifiers", scoring.getActiveModifiers().stream() .map(StrategyModifier::getTitle).collect(Collectors.toList())); strategyData.put("scores", Map.of( - "entry", scoring.getEntryScore(), - "authority", scoring.getAuthorityScore(), - "trust", scoring.getTrustScore(), - "conversion", scoring.getConversionScore() - )); + "entry", scoring.getEntryScore(), + "authority", scoring.getAuthorityScore(), + "trust", scoring.getTrustScore(), + "conversion", scoring.getConversionScore())); populateStrategyEntity(strategy, strategyData, scoring); repository.save(strategy); @@ -201,42 +203,108 @@ public class MarketingStrategyV3Service { if (req.getCustomerBehaviors() != null) { for (var cb : req.getCustomerBehaviors()) { switch (cb) { - case BUY_QUICKLY -> { conversion += 3; rationaleFactors.add("клиенты покупают быстро"); } - case COMPARE_OPTIONS -> { authority += 2; trust += 1; rationaleFactors.add("клиенты сравнивают варианты"); } - case REQUEST_PROPOSAL-> { authority += 3; rationaleFactors.add("клиенты просят расчёт/КП"); } - case CHECK_REVIEWS -> { trust += 3; rationaleFactors.add("клиенты проверяют кейсы и отзывы"); } - case NEED_CONSULTATION->{ authority += 3; rationaleFactors.add("клиенты приходят на консультацию"); } + case BUY_QUICKLY -> { + conversion += 3; + rationaleFactors.add("клиенты покупают быстро"); + } + case COMPARE_OPTIONS -> { + authority += 2; + trust += 1; + rationaleFactors.add("клиенты сравнивают варианты"); + } + case REQUEST_PROPOSAL -> { + authority += 3; + rationaleFactors.add("клиенты просят расчёт/КП"); + } + case CHECK_REVIEWS -> { + trust += 3; + rationaleFactors.add("клиенты проверяют кейсы и отзывы"); + } + case NEED_CONSULTATION -> { + authority += 3; + rationaleFactors.add("клиенты приходят на консультацию"); + } } } } if (req.getAverageCheck() != null) { switch (req.getAverageCheck()) { - case LOW -> { conversion += 2; rationaleFactors.add("низкий средний чек"); } - case MEDIUM -> { authority += 1; trust += 1; conversion += 1; rationaleFactors.add("средний чек"); } - case HIGH -> { authority += 2; trust += 1; rationaleFactors.add("высокий средний чек"); } + case LOW -> { + conversion += 2; + rationaleFactors.add("низкий средний чек"); + } + case MEDIUM -> { + authority += 1; + trust += 1; + conversion += 1; + rationaleFactors.add("средний чек"); + } + case HIGH -> { + authority += 2; + trust += 1; + rationaleFactors.add("высокий средний чек"); + } } } if (req.getBusinessStage() != null) { switch (req.getBusinessStage()) { - case JUST_STARTING -> { entry += 3; rationaleFactors.add("бизнес только запускается"); } - case LESS_THAN_ONE_YEAR -> { entry += 2; authority += 1; rationaleFactors.add("менее года"); } - case ONE_TO_THREE_YEARS -> { authority += 1; trust += 1; conversion += 1; rationaleFactors.add("1–3 года"); } - case MORE_THAN_THREE_YEARS-> { authority += 1; trust += 1; conversion += 1; rationaleFactors.add("более 3 лет"); } + case JUST_STARTING -> { + entry += 3; + rationaleFactors.add("бизнес только запускается"); + } + case LESS_THAN_ONE_YEAR -> { + entry += 2; + authority += 1; + rationaleFactors.add("менее года"); + } + case ONE_TO_THREE_YEARS -> { + authority += 1; + trust += 1; + conversion += 1; + rationaleFactors.add("1–3 года"); + } + case MORE_THAN_THREE_YEARS -> { + authority += 1; + trust += 1; + conversion += 1; + rationaleFactors.add("более 3 лет"); + } } } if (req.getClientTarget() != null) { switch (req.getClientTarget()) { - case BUSINESS -> { authority += 2; trust += 1; rationaleFactors.add("аудитория B2B"); } - case PRIVATE_CLIENTS -> { trust += 1; conversion += 2; rationaleFactors.add("аудитория B2C"); } - case MIXED -> { authority += 1; trust += 1; conversion += 1; rationaleFactors.add("B2B+B2C"); } + case BUSINESS -> { + authority += 2; + trust += 1; + rationaleFactors.add("аудитория B2B"); + } + case PRIVATE_CLIENTS -> { + trust += 1; + conversion += 2; + rationaleFactors.add("аудитория B2C"); + } + case MIXED -> { + authority += 1; + trust += 1; + conversion += 1; + rationaleFactors.add("B2B+B2C"); + } } } if (analysis != null && analysis.getResultData() != null && analysis.getResultData().getCompetitionIntensity() != null) { int cii = analysis.getResultData().getCompetitionIntensity().getCiiIndex(); - if (cii < 35) { conversion += 1; rationaleFactors.add("низкая конкуренция (CII=" + cii + ")"); } - else if (cii < 65) { trust += 1; conversion += 1; rationaleFactors.add("средняя конкуренция (CII=" + cii + ")"); } - else { trust += 2; rationaleFactors.add("высокая конкуренция (CII=" + cii + ")"); } + if (cii < 35) { + conversion += 1; + rationaleFactors.add("низкая конкуренция (CII=" + cii + ")"); + } else if (cii < 65) { + trust += 1; + conversion += 1; + rationaleFactors.add("средняя конкуренция (CII=" + cii + ")"); + } else { + trust += 2; + rationaleFactors.add("высокая конкуренция (CII=" + cii + ")"); + } } int maxScore = Math.max(Math.max(entry, authority), Math.max(trust, conversion)); @@ -262,12 +330,18 @@ public class MarketingStrategyV3Service { boolean isLaunch = req.getBusinessStage() != null && req.getBusinessStage() == kz.konturai.parser.enums.BusinessStage.JUST_STARTING; - if (authority == maxScore && trust == maxScore && highCheck) return StrategyModel.AUTHORITY; - if (trust == maxScore && conversion == maxScore && highComp) return StrategyModel.TRUST; - if (entry == maxScore && isLaunch) return StrategyModel.ENTRY; - if (authority == maxScore) return StrategyModel.AUTHORITY; - if (conversion == maxScore) return StrategyModel.CONVERSION; - if (trust == maxScore) return StrategyModel.TRUST; + if (authority == maxScore && trust == maxScore && highCheck) + return StrategyModel.AUTHORITY; + if (trust == maxScore && conversion == maxScore && highComp) + return StrategyModel.TRUST; + if (entry == maxScore && isLaunch) + return StrategyModel.ENTRY; + if (authority == maxScore) + return StrategyModel.AUTHORITY; + if (conversion == maxScore) + return StrategyModel.CONVERSION; + if (trust == maxScore) + return StrategyModel.TRUST; return StrategyModel.ENTRY; } @@ -294,13 +368,14 @@ public class MarketingStrategyV3Service { if (mods.size() < 3) { List cities = req.getPromotionCities() != null ? req.getPromotionCities() : (req.getPresenceCities() != null ? req.getPresenceCities() : List.of()); - if (cities.size() > 1) mods.add(StrategyModifier.MULTI_GEO); + if (cities.size() > 1) + mods.add(StrategyModifier.MULTI_GEO); } return mods; } private String buildRationaleText(StrategyModel model, List factors, - int entry, int authority, int trust, int conversion) { + int entry, int authority, int trust, int conversion) { String top = factors.stream().limit(3).collect(Collectors.joining(", ")); return String.format("Выбрана модель %s (E=%d Au=%d T=%d C=%d). Причины: %s.", model.getTitle(), entry, authority, trust, conversion, @@ -312,13 +387,14 @@ public class MarketingStrategyV3Service { // ===================================================================== /** - * Системный промпт — теперь включает контекст ниши для более релевантного контента. + * Системный промпт — теперь включает контекст ниши для более релевантного + * контента. */ private String buildSystemPrompt(ScoringResult scoring, MarketingAnalysisV3Document analysis) { String modifiersBlock = scoring.getActiveModifiers().isEmpty() ? "Нет активных модификаторов." : scoring.getActiveModifiers().stream() - .map(StrategyModifier::getInstructions).collect(Collectors.joining("\n")); + .map(StrategyModifier::getInstructions).collect(Collectors.joining("\n")); // Извлекаем конкурентов из анализа для примеров контента String competitorContext = ""; @@ -333,134 +409,214 @@ public class MarketingStrategyV3Service { } String niche = (analysis != null && analysis.getRequestData() != null) - ? analysis.getRequestData().getBusinessNiche() : "бизнес"; + ? analysis.getRequestData().getBusinessNiche() + : "бизнес"; String product = (analysis != null && analysis.getRequestData() != null) - ? analysis.getRequestData().getProductName() : ""; + ? analysis.getRequestData().getProductName() + : ""; return String.format(""" - ТЫ — СТАРШИЙ SMM СТРАТЕГ РЫНКА КАЗАХСТАНА С 15-ЛЕТНИМ ОПЫТОМ. - Твоя задача — создать контент-план, который РЕАЛЬНО ПРИНОСИТ ЛИДЫ. + ТЫ — СТАРШИЙ SMM СТРАТЕГ РЫНКА КАЗАХСТАНА С 15-ЛЕТНИМ ОПЫТОМ. + Твоя задача — создать контент-план, который РЕАЛЬНО ПРИНОСИТ ЛИДЫ. - ════════ БИЗНЕС КЛИЕНТА ════════ - Ниша: %s - Продукт/Бренд: %s - Выбранная стратегическая модель: %s - Обоснование: %s + ════════ БИЗНЕС КЛИЕНТА ════════ + Ниша: %s + Продукт/Бренд: %s + Выбранная стратегическая модель: %s + Обоснование: %s - ════════ КОНКУРЕНТЫ (учти при создании контента) ════════ - %s + ════════ КОНКУРЕНТЫ (учти при создании контента) ════════ + %s - ════════ ПРАВИЛА МОДЕЛИ (СТРОГО) ════════ - %s + ════════ ПРАВИЛА МОДЕЛИ (СТРОГО) ════════ + %s - ════════ МОДИФИКАТОРЫ ════════ - %s + ════════ МОДИФИКАТОРЫ ════════ + %s - ════════ КРИТИЧЕСКИЕ ПРАВИЛА — НАРУШАТЬ ЗАПРЕЩЕНО ════════ + ════════ КРИТИЧЕСКИЕ ПРАВИЛА — НАРУШАТЬ ЗАПРЕЩЕНО ════════ - ПРАВИЛО 1 — ТЕМА ПОСТА = КОНКРЕТНАЯ СИТУАЦИЯ В НИШЕ: - Каждый пост — это конкретная история или кейс из ниши "%s". - ЗАПРЕЩЕНО: абстрактные темы ("О нашей компании", "Введение в продукт") - ОБЯЗАТЕЛЬНО: конкретика ("Как выбрать ракетку для начинающего", "3 ошибки при первой тренировке") + ПРАВИЛО 1 — ТЕМА ПОСТА = КОНКРЕТНАЯ СИТУАЦИЯ В НИШЕ: + Каждый пост — это конкретная история или кейс из ниши "%s". + ЗАПРЕЩЕНО: абстрактные темы ("О нашей компании", "Введение в продукт") + ОБЯЗАТЕЛЬНО: конкретика ("Как выбрать ракетку для начинающего", "3 ошибки при первой тренировке") - ПРАВИЛО 2 — ТЕКСТ ПОСТА = ПРОДАЮЩИЙ КОНТЕНТ НА РУССКОМ: - - Минимум 150 слов живого текста - - Первые 2 строки — КРЮЧОК, который останавливает скролл - - Конкретные цифры, факты, примеры из казахстанской реальности - - В конце — ОДИН конкретный призыв к действию (запись, звонок, директ) - - Тенге, алматинские районы, казахстанские реалии + ПРАВИЛО 2 — ТЕКСТ ПОСТА = ПРОДАЮЩИЙ КОНТЕНТ НА РУССКОМ: + - Минимум 150 слов живого текста + - Первые 2 строки — КРЮЧОК, который останавливает скролл + - Конкретные цифры, факты, примеры из казахстанской реальности + - В конце — ОДИН конкретный призыв к действию (запись, звонок, директ) + - Тенге, алматинские районы, казахстанские реалии - ПРАВИЛО 3 — ХЭШТЕГИ (ТОЛЬКО ОДИНОЧНЫЙ #): - - От 5 до 8 хэштегов на русском/казахском - - Каждый хэштег начинается с ОДНОГО символа # - - ЗАПРЕЩЕНО: ##хэштег, # хэштег, английские хэштеги - - ПРАВИЛЬНО: #теннисалматы #спортказахстан #алматы + ПРАВИЛО 3 — ХЭШТЕГИ (ТОЛЬКО ОДИНОЧНЫЙ #): + - От 5 до 8 хэштегов на русском/казахском + - Каждый хэштег начинается с ОДНОГО символа # + - ЗАПРЕЩЕНО: ##хэштег, # хэштег, английские хэштеги + - ПРАВИЛЬНО: #теннисалматы #спортказахстан #алматы - ПРАВИЛО 4 — ФОРМАТЫ: - - contentType: ТОЛЬКО "фото" или "видео" - - РОВНО 2 видео-поста на весь план — расставить на самые важные посты - - Остальные посты — "фото" + ПРАВИЛО 4 — ФОРМАТЫ: + - contentType: ТОЛЬКО "фото" или "видео" + - РОВНО 2 видео-поста на весь план — расставить на самые важные посты + - Остальные посты — "фото" - ПРАВИЛО 5 — РАСПИСАНИЕ: - - Instagram: 09:00, 12:00, 18:00, 20:00 - - TikTok: 18:00, 20:00, 22:00 - - Не публиковать чаще 1 раза в день на одной платформе + ПРАВИЛО 5 — РАСПИСАНИЕ: + - Instagram: 09:00, 12:00, 18:00, 20:00 + - TikTok: 18:00, 20:00, 22:00 + - Не публиковать чаще 1 раза в день на одной платформе - ВЫВОДИ ТОЛЬКО ЧИСТЫЙ JSON! Без markdown, без пояснений. - """, + ВЫВОДИ ТОЛЬКО ЧИСТЫЙ JSON! Без markdown, без пояснений. + """, niche, product, scoring.getModel().getTitle(), scoring.getRationale(), competitorContext.isEmpty() ? "Данные конкурентов не загружены." : competitorContext, scoring.getModel().getContentRules().replace("%", "%%"), modifiersBlock.replace("%", "%%"), - niche - ); + niche); } private String buildUserPrompt(MarketingAnalysisV3Document analysis, - ScoringResult scoring) throws Exception { - String clientJson = objectMapper.writeValueAsString(analysis.getRequestData()); - String analysisCtx = buildAnalysisContext(analysis); + ScoringResult scoring) throws Exception { + String clientJson = objectMapper.writeValueAsString(analysis.getRequestData()); + String analysisCtx = buildAnalysisContext(analysis); String postCountPlan = buildPostCountPlan(scoring.getModel(), 4); LocalDateTime startDate = LocalDateTime.now().plusDays(1); String niche = analysis.getRequestData() != null - ? analysis.getRequestData().getBusinessNiche() : "бизнес"; + ? analysis.getRequestData().getBusinessNiche() + : "бизнес"; - return String.format(""" - ЗАДАНИЕ: Создай SMM-стратегию на 4 недели для "%s" на рынке Казахстана. - Стратегия должна РЕАЛЬНО ПРИВЛЕКАТЬ КЛИЕНТОВ И ПРИНОСИТЬ ЛИДЫ. + return String.format( + """ + ЗАДАНИЕ: Создай ПРОФЕССИОНАЛЬНУЮ SMM-стратегию на 4 недели для "%s" на рынке Казахстана. + Стратегия должна РЕАЛЬНО ПРИВЛЕКАТЬ КЛИЕНТОВ И ПРИНОСИТЬ ЛИДЫ. - ══ ПРОФИЛЬ БИЗНЕСА ══ - %s + ══ ПРОФИЛЬ БИЗНЕСА ══ + %s - ══ ДАННЫЕ РЫНКА ══ - %s + ══ ДАННЫЕ РЫНКА ══ + %s - ══ ПАРАМЕТРЫ ══ - Старт: %s | Модель: %s | Модификаторы: %s + ══ ПАРАМЕТРЫ ══ + Старт: %s | Модель: %s | Модификаторы: %s - ══ ПЛАН ПОСТОВ ══ - %s + ══ ПЛАН ПОСТОВ ══ + %s - ══ JSON СТРУКТУРА ══ - { - "selectedModel": "%s", - "scoringRationale": "%s", - "recommendedDurationWeeks": 4, - "recommendedPlatforms": ["Instagram", "TikTok"], - "rationale": "Минимум 60 слов — почему именно эти платформы для данной ниши", - "weeklyPlans": [ - { - "weekNumber": 1, - "mainThemes": ["Конкретная тема 1 для ниши", "Конкретная тема 2"], - "contentRecommendations": "Что конкретно снимать и писать — минимум 50 слов", - "priorityPlatforms": ["Instagram"] - } - ], - "postCalendar": [ - { - "publishDate": "2024-01-15T10:00:00", - "platform": "Instagram", - "contentType": "фото", - "theme": "КОНКРЕТНАЯ тема поста — не общая фраза, а конкретная ситуация из ниши", - "postText": "150+ слов живого текста на русском. Первые 2 строки — крючок. Цифры и факты. CTA в конце.", - "hashtags": ["#хэштег1", "#хэштег2", "#алматы"], - "publishTime": "10:00" - } - ] - } + ════════════════════════════════════════════════════════════════ + ⚠️ КРИТИЧЕСКИ ВАЖНО: РАСПРЕДЕЛЕНИЕ ПОСТОВ ПО НЕДЕЛЯМ + ════════════════════════════════════════════════════════════════ - ЖЁСТКИЕ ТРЕБОВАНИЯ: - 1. postCalendar: МИНИМУМ 12 постов, оптимально 14-16 - 2. РОВНО 2 поста с contentType: "видео", остальные "фото" - 3. theme: конкретная тема из ниши "%s" — никаких абстракций - 4. postText: 150+ слов, только русский язык - 5. hashtags: 5-8 штук, каждый начинается с одного #, только русский/казахский - 6. Равномерно распредели по 4 неделям + ОБЩЕЕ КОЛИЧЕСТВО: РОВНО 16 ПОСТОВ за 4 недели (4 поста в неделю). - ОТВЕТ: только JSON объект. Никакого текста вне JSON. - """, + РАСПРЕДЕЛЕНИЕ ПО НЕДЕЛЯМ — СТРОГО: + + НЕДЕЛЯ 1 (дни 1-7 от старта): + - Пост 1: День 1 (понедельник) — время 09:00 + - Пост 2: День 3 (среда) — время 12:00 + - Пост 3: День 5 (пятница) — время 18:00 + - Пост 4: День 6 (суббота) — время 20:00 + + НЕДЕЛЯ 2 (дни 8-14): + - Пост 5: День 8 (понедельник) — время 09:00 + - Пост 6: День 10 (среда) — время 12:00 + - Пост 7: День 12 (пятница) — время 18:00 + - Пост 8: День 13 (суббота) — время 20:00 + + НЕДЕЛЯ 3 (дни 15-21): + - Пост 9: День 15 (понедельник) — время 09:00 + - Пост 10: День 17 (среда) — время 12:00 + - Пост 11: День 19 (пятница) — время 18:00 + - Пост 12: День 20 (суббота) — время 20:00 + + НЕДЕЛЯ 4 (дни 22-28): + - Пост 13: День 22 (понедельник) — время 09:00 + - Пост 14: День 24 (среда) — время 12:00 + - Пост 15: День 26 (пятница) — время 18:00 + - Пост 16: День 27 (суббота) — время 20:00 + + ВАЖНО: + - КАЖДЫЙ пост в postCalendar должен иметь поле "weekNumber" (1, 2, 3 или 4) + - Посты НЕ должны пересекаться между неделями + - Соблюдай равномерный ритм: Пн-Ср-Пт-Сб каждую неделю + - Никаких пропусков в 2-3 дня между постами внутри одной недели! + + ══ JSON СТРУКТУРА ══ + { + "selectedModel": "%s", + "scoringRationale": "%s", + "recommendedDurationWeeks": 4, + "recommendedPlatforms": ["Instagram", "TikTok"], + "rationale": "Минимум 60 слов — почему именно эти платформы для данной ниши", + "weeklyPlans": [ + { + "weekNumber": 1, + "mainThemes": [ + "Конкретная тема 1 для недели 1", + "Конкретная тема 2 для недели 1", + "Конкретная тема 3 для недели 1", + "Конкретная тема 4 для недели 1" + ], + "contentRecommendations": "Что конкретно снимать и писать на неделе 1 — минимум 50 слов", + "priorityPlatforms": ["Instagram"] + } + ], + "postCalendar": [ + { + "publishDate": "2024-01-15T09:00:00", + "weekNumber": 1, + "platform": "Instagram", + "contentType": "фото", + "theme": "КОНКРЕТНАЯ тема поста — не общая фраза, а конкретная ситуация из ниши", + "postText": "150+ слов живого текста на русском. Первые 2 строки — крючок. Цифры и факты. CTA в конце.", + "hashtags": ["#хэштег1", "#хэштег2", "#алматы"], + "publishTime": "09:00" + } + ] + } + + ЖЁСТКИЕ ТРЕБОВАНИЯ: + 1. postCalendar: РОВНО 16 постов (4 поста × 4 недели) + 2. РОВНО 4 поста с contentType: "видео" (по 1 видео в неделю), остальные "фото" + 3. КАЖДЫЙ пост должен иметь "weekNumber": 1, 2, 3 или 4 + 4. Даты должны соответствовать неделе: weekNumber 1 = дни 1-7, weekNumber 2 = дни 8-14 и т.д. + 5. theme: конкретная тема из ниши "%s" — никаких абстракций + 6. postText: 150+ слов, только русский язык + 7. hashtags: 5-8 штук, каждый начинается с одного #, только русский/казахский + 8. weeklyPlans: у каждой недели должно быть РОВНО 4 темы в mainThemes + + ПРИМЕР ПРАВИЛЬНОГО РАСПРЕДЕЛЕНИЯ: + { + "weeklyPlans": [ + { + "weekNumber": 1, + "mainThemes": [ + "Кейс: Как мы увеличили продажи клиента на 40%% за месяц", + "Процесс: За кадром нашей работы", + "Отзыв: История довольного клиента из Алматы", + "Экспертный: 5 ошибок при выборе услуги" + ] + } + ], + "postCalendar": [ + { + "publishDate": "2026-04-13T09:00:00", + "weekNumber": 1, + "platform": "Instagram", + "contentType": "видео", + "theme": "Кейс: Как мы увеличили продажи клиента на 40%% за месяц" + }, + { + "publishDate": "2026-04-15T12:00:00", + "weekNumber": 1, + "platform": "Instagram", + "contentType": "фото", + "theme": "Процесс: За кадром нашей работы" + } + ] + } + + ОТВЕТ: только JSON объект. Никакого текста вне JSON. + """, niche, clientJson, analysisCtx, startDate.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), scoring.getModel().getTitle(), @@ -469,8 +625,7 @@ public class MarketingStrategyV3Service { postCountPlan, scoring.getModel().name(), scoring.getRationale().replace("\"", "'"), - niche - ); + niche); } private String buildAnalysisContext(MarketingAnalysisV3Document analysis) { @@ -510,10 +665,17 @@ public class MarketingStrategyV3Service { } private String buildPostCountPlan(StrategyModel model, int weeks) { - int total = weeks * 3, optimal = weeks * 4; + int total = weeks * 4; // 4 поста в неделю × 4 недели = 16 постов StringBuilder sb = new StringBuilder(); - sb.append("Минимум: ").append(total).append(" | Оптимально: ").append(optimal).append("\n"); - sb.append("РОВНО 2 видео-поста, остальные фото\n\nТипы постов:\n"); + sb.append("Общее количество: ").append(total).append(" постов (4 поста/неделю × ").append(weeks) + .append(" недели)\n"); + sb.append("РОВНО 4 видео-поста (по 1 в неделю), остальные фото\n\n"); + sb.append("РАСПРЕДЕЛЕНИЕ ПО НЕДЕЛЯМ:\n"); + sb.append(" Неделя 1: 4 поста (Пн 09:00, Ср 12:00, Пт 18:00, Сб 20:00)\n"); + sb.append(" Неделя 2: 4 поста (Пн 09:00, Ср 12:00, Пт 18:00, Сб 20:00)\n"); + sb.append(" Неделя 3: 4 поста (Пн 09:00, Ср 12:00, Пт 18:00, Сб 20:00)\n"); + sb.append(" Неделя 4: 4 поста (Пн 09:00, Ср 12:00, Пт 18:00, Сб 20:00)\n\n"); + sb.append("Типы постов:\n"); switch (model) { case ENTRY -> { sb.append(" Объясняющий: ").append(pct(total, 0.30)).append(" (30%)\n"); @@ -547,22 +709,27 @@ public class MarketingStrategyV3Service { return sb.toString(); } - private int pct(int total, double ratio) { return (int) Math.round(total * ratio); } + private int pct(int total, double ratio) { + return (int) Math.round(total * ratio); + } // ===================================================================== // HASHTAG CLEANUP — чистим ## и лишние пробелы // ===================================================================== private void cleanHashtags(MarketingStrategy strategy) { - if (strategy.getPostCalendar() == null) return; + if (strategy.getPostCalendar() == null) + return; for (MarketingStrategy.PostCalendarItem item : strategy.getPostCalendar()) { - if (item.getHashtags() == null) continue; + if (item.getHashtags() == null) + continue; List cleaned = item.getHashtags().stream() .map(tag -> { // Убираем пробелы tag = tag.trim(); // Убираем все # в начале - while (tag.startsWith("#")) tag = tag.substring(1); + while (tag.startsWith("#")) + tag = tag.substring(1); // Убираем пробелы внутри тега tag = tag.replaceAll("\\s+", ""); // Добавляем ровно один # @@ -581,9 +748,9 @@ public class MarketingStrategyV3Service { @SuppressWarnings("unchecked") private void generateMediaAssets(MarketingStrategy strategy, - MarketingAnalysisV3Document analysis) { - String niche = safeGet(analysis, a -> a.getRequestData().getBusinessNiche(), "бизнес"); - String brand = safeGet(analysis, a -> a.getRequestData().getProductName(), ""); + MarketingAnalysisV3Document analysis) { + String niche = safeGet(analysis, a -> a.getRequestData().getBusinessNiche(), "бизнес"); + String brand = safeGet(analysis, a -> a.getRequestData().getProductName(), ""); String mainCity = safeGet(analysis, a -> { String c = a.getRequestData().getMainCity(); return (c != null && !c.isBlank()) ? c : "Алматы"; @@ -595,9 +762,9 @@ public class MarketingStrategyV3Service { refFiles = (List) strategy.getStrategyData().get("referenceFilenames"); } - Random random = new Random(); - int total = strategy.getPostCalendar().size(); - int current = 0; + Random random = new Random(); + int total = strategy.getPostCalendar().size(); + int current = 0; // Считаем сколько видео уже запланировано AI long plannedVideos = strategy.getPostCalendar().stream() @@ -605,14 +772,31 @@ public class MarketingStrategyV3Service { .count(); log.info("[Media] Total posts={}, planned videos={}", total, plannedVideos); - // Если AI не назначил ни одного видео — принудительно назначаем 2 + // Если AI не назначил ни одного видео — принудительно назначаем 4 (по 1 в + // неделю) if (plannedVideos == 0) { - log.warn("[Media] AI не назначил ни одного видео! Принудительно назначаю 2 поста как видео."); + log.warn("[Media] AI не назначил ни одного видео! Принудительно назначаю 4 поста как видео."); int assigned = 0; for (MarketingStrategy.PostCalendarItem item : strategy.getPostCalendar()) { - if (assigned >= 2) break; - item.setContentType("видео"); - assigned++; + if (assigned >= 4) + break; + // Назначаем видео на первый пост каждой недели + if (item.getWeekNumber() != null && (item.getWeekNumber() - 1) * 4 <= assigned + && assigned < (item.getWeekNumber()) * 4) { + item.setContentType("видео"); + assigned++; + } + } + // Если не получилось назначить по неделе, просто назначаем первые 4 + if (assigned < 4) { + for (MarketingStrategy.PostCalendarItem item : strategy.getPostCalendar()) { + if (assigned >= 4) + break; + if (!"видео".equals(item.getContentType())) { + item.setContentType("видео"); + assigned++; + } + } } } @@ -621,18 +805,20 @@ public class MarketingStrategyV3Service { for (MarketingStrategy.PostCalendarItem item : strategy.getPostCalendar()) { current++; String contentType = item.getContentType() != null - ? item.getContentType().trim().toLowerCase() : "фото"; + ? item.getContentType().trim().toLowerCase() + : "фото"; // Нормализуем — на случай если AI написал что-то кроме "фото"/"видео" - if (!contentType.equals("видео")) contentType = "фото"; + if (!contentType.equals("видео")) + contentType = "фото"; log.info("[Media {}/{}] type='{}' platform='{}' theme='{}'", current, total, contentType, item.getPlatform(), item.getTheme()); - // Лимит: максимум 2 видео - if (contentType.equals("видео") && videoGenerated >= 2) { - log.warn("[Media] Video limit (2) reached — switching post {} to photo", current); + // Лимит: максимум 4 видео (по 1 в неделю) + if (contentType.equals("видео") && videoGenerated >= 4) { + log.warn("[Media] Video limit (4) reached — switching post {} to photo", current); contentType = "фото"; item.setContentType("фото"); } @@ -663,7 +849,8 @@ public class MarketingStrategyV3Service { doGenerateImage(item, niche, brand, mainCity, refBytes); } - if (delayBetweenRequestsMs > 0) Thread.sleep(delayBetweenRequestsMs); + if (delayBetweenRequestsMs > 0) + Thread.sleep(delayBetweenRequestsMs); } catch (Exception e) { log.error("[Media] Error for post {}: {}", current, e.getMessage()); @@ -686,12 +873,13 @@ public class MarketingStrategyV3Service { * Возвращает true если видео успешно сгенерировано. */ private boolean doGenerateVideo(MarketingStrategy.PostCalendarItem item, - String niche, String brand, String city) { + String niche, String brand, String city) { - String theme = item.getTheme() != null ? item.getTheme() : "Product presentation"; + String theme = item.getTheme() != null ? item.getTheme() : "Product presentation"; String postText = item.getPostText() != null ? item.getPostText() : ""; String platform = item.getPlatform() != null - ? item.getPlatform().toLowerCase() : "instagram"; + ? item.getPlatform().toLowerCase() + : "instagram"; // Строим промпт на английском String englishPrompt = buildVideoPromptEnglish(theme, postText, niche, brand, city, platform); @@ -705,7 +893,8 @@ public class MarketingStrategyV3Service { log.info("[VEO3] Niche : {} | Brand : {} | City : {}", niche, brand, city); log.info("[VEO3] Prompt : {}", englishPrompt.length() > 400 - ? englishPrompt.substring(0, 400) + "..." : englishPrompt); + ? englishPrompt.substring(0, 400) + "..." + : englishPrompt); try { byte[] videoBytes = geminiVideoService.generateVideo(englishPrompt); @@ -745,8 +934,8 @@ public class MarketingStrategyV3Service { * [RULES] → что запрещено (текст в кадре и т.д.) */ private String buildVideoPromptEnglish(String theme, String postText, - String niche, String brand, - String city, String platform) { + String niche, String brand, + String city, String platform) { String speechScript = postText.length() > 250 ? postText.substring(0, 250).trim() : postText.trim(); String sceneType = resolveSceneType(theme, niche); @@ -772,15 +961,17 @@ public class MarketingStrategyV3Service { "Business niche: %s. Brand: %s. " + "Scene: %s. " + "Visual concept: %s. " + - "People: Kazakh appearance, natural authentic look, real emotions, perfect anatomy, photorealistic. " + + "People: Kazakh appearance, natural authentic look, real emotions, perfect anatomy, photorealistic. " + + "Style: %s. " + - "Quality: 8K resolution, professional lighting, sharp focus, no blur, no CGI, photorealistic, commercial grade. " + + "Quality: 8K resolution, professional lighting, sharp focus, no blur, no CGI, photorealistic, commercial grade. " + + "Sound design: %s. " + - "CRITICAL AUDIO INSTRUCTION: The narrator, voiceover, or characters MUST speak strictly in the RUSSIAN language with natural intonation. " + + "CRITICAL AUDIO INSTRUCTION: The narrator, voiceover, or characters MUST speak strictly in the RUSSIAN language with natural intonation. " + + "Use the following Russian text as the exact spoken dialogue/voiceover script: \"%s\". " + "STRICT RULES: NO text on screen. NO subtitles. NO captions. NO watermarks. NO on-screen graphics. NO logos overlaid. NO artificial CGI look.", - platform, city, niche, brand, sceneType, theme, style, audio, speechScript - ); + platform, city, niche, brand, sceneType, theme, style, audio, speechScript); } /** @@ -788,7 +979,8 @@ public class MarketingStrategyV3Service { * Переводит суть темы в описание конкретной сцены на английском. */ private String resolveSceneType(String theme, String niche) { - if (theme == null) return "business activity scene in " + niche; + if (theme == null) + return "business activity scene in " + niche; String t = theme.toLowerCase(); @@ -797,7 +989,8 @@ public class MarketingStrategyV3Service { return "happy satisfied client sharing their success story, before/after transformation"; // Обучение и советы - if (t.contains("совет") || t.contains("как") || t.contains("обучен") || t.contains("урок") || t.contains("разбор")) + if (t.contains("совет") || t.contains("как") || t.contains("обучен") || t.contains("урок") + || t.contains("разбор")) return "expert explaining key tips and techniques in " + niche + ", educational demonstration"; // Процесс работы @@ -828,12 +1021,14 @@ public class MarketingStrategyV3Service { return "authentic lifestyle scene showing " + niche + " activity, " + "real people benefiting from the service/product"; } + /** * Генерация изображений. - * КЛЮЧЕВОЕ ИЗМЕНЕНИЕ: промпт строится из темы И текста поста — картинка строго в тему. + * КЛЮЧЕВОЕ ИЗМЕНЕНИЕ: промпт строится из темы И текста поста — картинка строго + * в тему. */ private void doGenerateImage(MarketingStrategy.PostCalendarItem item, - String niche, String brand, String city, byte[] refBytes) { + String niche, String brand, String city, byte[] refBytes) { String imagePrompt = buildImagePrompt(item, niche, brand, city); log.info("[Image] Generating. Theme='{}'", item.getTheme()); log.debug("[Image] Prompt: {}", imagePrompt); @@ -863,11 +1058,12 @@ public class MarketingStrategyV3Service { /** * ГЛАВНОЕ УЛУЧШЕНИЕ: промпт для изображения строится из КОНКРЕТНОЙ темы поста. - * Картинка должна визуально иллюстрировать тему — а не быть случайным портретом. + * Картинка должна визуально иллюстрировать тему — а не быть случайным + * портретом. */ private String buildImagePrompt(MarketingStrategy.PostCalendarItem item, - String niche, String brand, String city) { - String theme = item.getTheme() != null ? item.getTheme() : ""; + String niche, String brand, String city) { + String theme = item.getTheme() != null ? item.getTheme() : ""; String postText = item.getPostText() != null ? item.getPostText() : ""; String platform = item.getPlatform() != null ? item.getPlatform().toLowerCase() : "instagram"; @@ -918,19 +1114,22 @@ public class MarketingStrategyV3Service { @SuppressWarnings("unchecked") public MarketingStrategy.PostCalendarItem regeneratePostImage(String strategyId, int postIndex) { MarketingStrategy strategy = repository.findById(strategyId).orElse(null); - if (strategy == null) return null; + if (strategy == null) + return null; List calendar = strategy.getPostCalendar(); - if (calendar == null || postIndex < 0 || postIndex >= calendar.size()) return null; + if (calendar == null || postIndex < 0 || postIndex >= calendar.size()) + return null; MarketingStrategy.PostCalendarItem item = calendar.get(postIndex); item.setContentType("фото"); MarketingAnalysisV3Document analysis = analysisRepository .findById(strategy.getAnalysisId()).orElse(null); - if (analysis == null) return null; + if (analysis == null) + return null; - String niche = safeGet(analysis, a -> a.getRequestData().getBusinessNiche(), "бизнес"); - String brand = safeGet(analysis, a -> a.getRequestData().getProductName(), ""); + String niche = safeGet(analysis, a -> a.getRequestData().getBusinessNiche(), "бизнес"); + String brand = safeGet(analysis, a -> a.getRequestData().getProductName(), ""); String mainCity = safeGet(analysis, a -> { String c = a.getRequestData().getMainCity(); return (c != null && !c.isBlank()) ? c : "Алматы"; @@ -959,11 +1158,15 @@ public class MarketingStrategyV3Service { public void regeneratePostVideoAsync(String strategyId, int postIndex) { try { MarketingStrategy strategy = repository.findById(strategyId).orElse(null); - if (strategy == null) { log.error("[VideoRegen] Strategy {} not found", strategyId); return; } + if (strategy == null) { + log.error("[VideoRegen] Strategy {} not found", strategyId); + return; + } List calendar = strategy.getPostCalendar(); if (calendar == null || postIndex < 0 || postIndex >= calendar.size()) { - log.error("[VideoRegen] Invalid postIndex {}", postIndex); return; + log.error("[VideoRegen] Invalid postIndex {}", postIndex); + return; } MarketingStrategy.PostCalendarItem item = calendar.get(postIndex); @@ -976,10 +1179,13 @@ public class MarketingStrategyV3Service { MarketingAnalysisV3Document analysis = analysisRepository .findById(strategy.getAnalysisId()).orElse(null); - if (analysis == null) { log.error("[VideoRegen] Analysis not found"); return; } + if (analysis == null) { + log.error("[VideoRegen] Analysis not found"); + return; + } - String niche = safeGet(analysis, a -> a.getRequestData().getBusinessNiche(), "бизнес"); - String brand = safeGet(analysis, a -> a.getRequestData().getProductName(), ""); + String niche = safeGet(analysis, a -> a.getRequestData().getBusinessNiche(), "бизнес"); + String brand = safeGet(analysis, a -> a.getRequestData().getProductName(), ""); String mainCity = safeGet(analysis, a -> { String c = a.getRequestData().getMainCity(); return (c != null && !c.isBlank()) ? c : "Алматы"; @@ -1006,31 +1212,34 @@ public class MarketingStrategyV3Service { public Map getStrategyResult(String strategyId) { MarketingStrategy strategy = repository.findById(strategyId).orElse(null); - if (strategy == null) return null; + if (strategy == null) + return null; Map response = new LinkedHashMap<>(); - response.put("strategyId", strategy.getId()); - response.put("analysisId", strategy.getAnalysisId()); - response.put("status", strategy.getStatus()); - response.put("createdAt", strategy.getCreatedAt()); - response.put("completedAt", strategy.getCompletedAt()); - response.put("durationWeeks", strategy.getDurationWeeks()); + response.put("strategyId", strategy.getId()); + response.put("analysisId", strategy.getAnalysisId()); + response.put("status", strategy.getStatus()); + response.put("createdAt", strategy.getCreatedAt()); + response.put("completedAt", strategy.getCompletedAt()); + response.put("durationWeeks", strategy.getDurationWeeks()); response.put("priorityPlatforms", strategy.getPriorityPlatforms()); // Название модели — из entity, резерв из strategyData - String modelName = strategy.getScoringModelName(); + String modelName = strategy.getScoringModelName(); String modelTitle = strategy.getScoringModelTitle(); if (modelName == null && strategy.getStrategyData() != null) { Object v = strategy.getStrategyData().get("scoringModel"); - if (v != null) modelName = v.toString(); + if (v != null) + modelName = v.toString(); } if (modelTitle == null && strategy.getStrategyData() != null) { Object v = strategy.getStrategyData().get("scoringModelTitle"); - if (v != null) modelTitle = v.toString(); + if (v != null) + modelTitle = v.toString(); } - response.put("scoringModelName", modelName); + response.put("scoringModelName", modelName); response.put("scoringModelTitle", modelTitle); - response.put("strategyData", strategy.getStrategyData()); + response.put("strategyData", strategy.getStrategyData()); if (strategy.getWeeklyPlans() != null && strategy.getPostCalendar() != null) { List tasks = postingTaskService.getStrategyTasks(strategyId); @@ -1040,17 +1249,18 @@ public class MarketingStrategyV3Service { for (int i = 0; i < items.size(); i++) { MarketingStrategy.PostCalendarItem item = items.get(i); Map dto = new LinkedHashMap<>(); - dto.put("postIndex", i); - dto.put("publishDate", item.getPublishDate()); - dto.put("platform", item.getPlatform()); - dto.put("contentType", item.getContentType()); - dto.put("theme", item.getTheme()); - dto.put("postText", item.getPostText()); - dto.put("hashtags", item.getHashtags()); - dto.put("publishTime", item.getPublishTime()); - dto.put("imageUrl", item.getImageUrl()); + dto.put("postIndex", i); + dto.put("publishDate", item.getPublishDate()); + dto.put("weekNumber", item.getWeekNumber()); + dto.put("platform", item.getPlatform()); + dto.put("contentType", item.getContentType()); + dto.put("theme", item.getTheme()); + dto.put("postText", item.getPostText()); + dto.put("hashtags", item.getHashtags()); + dto.put("publishTime", item.getPublishTime()); + dto.put("imageUrl", item.getImageUrl()); dto.put("imageFilename", item.getImageFilename()); - dto.put("videoUrl", item.getVideoUrl()); + dto.put("videoUrl", item.getVideoUrl()); dto.put("videoFilename", item.getVideoFilename()); tasks.stream() @@ -1060,17 +1270,19 @@ public class MarketingStrategyV3Service { && t.getPlatform().equalsIgnoreCase(item.getPlatform())) .findFirst() .ifPresentOrElse( - t -> { dto.put("taskId", t.getId()); dto.put("taskStatus", t.getStatus()); }, - () -> dto.put("taskStatus", null) - ); + t -> { + dto.put("taskId", t.getId()); + dto.put("taskStatus", t.getStatus()); + }, + () -> dto.put("taskStatus", null)); postCalendarList.add(dto); } - response.put("strategy", Map.of("weeklyPlans", strategy.getWeeklyPlans(), + response.put("strategy", Map.of("weeklyPlans", strategy.getWeeklyPlans(), "postCalendar", postCalendarList)); response.put("postCalendar", postCalendarList); - response.put("weeklyPlans", strategy.getWeeklyPlans()); + response.put("weeklyPlans", strategy.getWeeklyPlans()); } return response; @@ -1086,9 +1298,10 @@ public class MarketingStrategyV3Service { } private T safeGet(MarketingAnalysisV3Document analysis, - AnalysisExtractor extractor, T defaultValue) { + AnalysisExtractor extractor, T defaultValue) { try { - if (analysis == null || analysis.getRequestData() == null) return defaultValue; + if (analysis == null || analysis.getRequestData() == null) + return defaultValue; T val = extractor.extract(analysis); return val != null ? val : defaultValue; } catch (Exception e) { @@ -1098,8 +1311,8 @@ public class MarketingStrategyV3Service { @SuppressWarnings("unchecked") private void populateStrategyEntity(MarketingStrategy strategy, - Map data, - ScoringResult scoring) { + Map data, + ScoringResult scoring) { strategy.setStrategyData(data); strategy.setScoringModelName(scoring.getModel().name()); strategy.setScoringModelTitle(scoring.getModel().getTitle()); @@ -1109,6 +1322,7 @@ public class MarketingStrategyV3Service { if (data.containsKey("recommendedPlatforms")) strategy.setPriorityPlatforms((List) data.get("recommendedPlatforms")); + // ===== ОБРАБОТКА НЕДЕЛЬНЫХ ПЛАНОВ ===== List> weeksRaw = (List>) data.get("weeklyPlans"); List weeklyPlans = new ArrayList<>(); if (weeksRaw != null) { @@ -1123,28 +1337,57 @@ public class MarketingStrategyV3Service { } strategy.setWeeklyPlans(weeklyPlans); + // ===== ОБРАБОТКА КАЛЕНДАРЯ ПОСТОВ ===== List> calendarRaw = (List>) data.get("postCalendar"); List postCalendar = new ArrayList<>(); LocalDateTime startDate = LocalDateTime.now().plusDays(1); - if (calendarRaw != null) { + if (calendarRaw != null && !calendarRaw.isEmpty()) { + // Валидация: если постов меньше 12, логируем предупреждение + if (calendarRaw.size() < 12) { + log.warn("[Strategy] AI вернул только {} постов (ожидалось 14-16). Применяю нормализацию...", + calendarRaw.size()); + } + + int postIndex = 0; for (Map c : calendarRaw) { MarketingStrategy.PostCalendarItem item = new MarketingStrategy.PostCalendarItem(); + + // Извлекаем weekNumber из ответа AI (если есть) + Integer weekNumber = c.get("weekNumber") != null + ? ((Number) c.get("weekNumber")).intValue() + : null; + + // Обработка даты String dateStr = (String) c.get("publishDate"); + LocalDateTime publishDate; + if (dateStr != null) { try { - item.setPublishDate( - LocalDateTime.parse(dateStr, DateTimeFormatter.ISO_LOCAL_DATE_TIME)); + publishDate = LocalDateTime.parse(dateStr, DateTimeFormatter.ISO_LOCAL_DATE_TIME); } catch (Exception e) { - item.setPublishDate(startDate.plusDays(postCalendar.size() * 2L)); + log.warn("[Strategy] Невалидная дата '{}', генерирую автоматически", dateStr); + publishDate = null; } } else { - item.setPublishDate(startDate.plusDays(postCalendar.size() * 2L)); + publishDate = null; } + // Если даты нет или она некорректна — рассчитываем по позиции + if (publishDate == null) { + int weekNum = weekNumber != null ? weekNumber : (postIndex / 4) + 1; + int dayInWeek = postIndex % 4; + int dayOffset = (weekNum - 1) * 7 + getDayOffset(dayInWeek); + publishDate = startDate.plusDays(dayOffset); + } + + item.setPublishDate(publishDate); + item.setWeekNumber(weekNumber != null ? weekNumber : (postIndex / 4) + 1); + // Нормализуем contentType String ct = (String) c.get("contentType"); - if (ct != null) ct = ct.trim().toLowerCase(); + if (ct != null) + ct = ct.trim().toLowerCase(); item.setContentType("видео".equals(ct) ? "видео" : "фото"); item.setPlatform((String) c.get("platform")); @@ -1152,9 +1395,15 @@ public class MarketingStrategyV3Service { item.setPostText((String) c.get("postText")); item.setHashtags((List) c.get("hashtags")); item.setPublishTime((String) c.get("publishTime")); + postCalendar.add(item); + postIndex++; } } + + // ===== НОРМАЛИЗАЦИЯ РАСПИСАНИЯ ===== + normalizePostSchedule(postCalendar, startDate); + strategy.setPostCalendar(postCalendar); log.info("Populated: {} weeklyPlans, {} posts, model={}, videos={}", @@ -1162,20 +1411,95 @@ public class MarketingStrategyV3Service { postCalendar.stream().filter(p -> "видео".equals(p.getContentType())).count()); } + /** + * Нормализует расписание постов — равномерное распределение по дням. + * Гарантирует: 4 поста в неделю, публикации в Пн-Ср-Пт-Сб. + */ + private void normalizePostSchedule(List postCalendar, + LocalDateTime startDate) { + if (postCalendar == null || postCalendar.isEmpty()) + return; + + int totalPosts = postCalendar.size(); + int weeksCount = (int) Math.ceil(totalPosts / 4.0); + + // Время публикации для каждого поста в неделе: Пн 09:00, Ср 12:00, Пт 18:00, Сб + // 20:00 + String[] weekTimeSlots = { "09:00", "12:00", "18:00", "20:00" }; + int[] dayOffsets = { 0, 2, 4, 5 }; // Пн=0, Ср=2, Пт=4, Сб=5 + + for (int i = 0; i < postCalendar.size(); i++) { + MarketingStrategy.PostCalendarItem item = postCalendar.get(i); + + // Определяем неделю и позицию в неделе + int weekNum = (i / 4) + 1; + int posInWeek = i % 4; + + // Устанавливаем weekNumber + item.setWeekNumber(weekNum); + + // Рассчитываем дату + int dayOffset = (weekNum - 1) * 7 + dayOffsets[posInWeek]; + LocalDateTime newDate = startDate.plusDays(dayOffset); + + // Если исходная дата была валидной и в пределах +/- 2 дней от новой, сохраняем + // её + // Иначе используем рассчитанную дату для равномерности + if (item.getPublishDate() != null) { + long daysDiff = java.time.Duration.between(item.getPublishDate(), newDate).toDays(); + if (Math.abs(daysDiff) > 2) { + item.setPublishDate(newDate); + } + } else { + item.setPublishDate(newDate); + } + + // Устанавливаем время публикации + item.setPublishTime(weekTimeSlots[posInWeek]); + + log.debug("[Normalize] Post {}: week={}, posInWeek={}, date={}, time={}", + i, weekNum, posInWeek, item.getPublishDate(), item.getPublishTime()); + } + } + + /** + * Возвращает смещение дня в неделе для позиции поста (0-3). + * 0=Пн, 1=Ср, 2=Пт, 3=Сб + */ + private int getDayOffset(int posInWeek) { + switch (posInWeek) { + case 0: + return 0; // Понедельник + case 1: + return 2; // Среда + case 2: + return 4; // Пятница + case 3: + return 5; // Суббота + default: + return 0; + } + } + private String extractCleanJson(String response) { - if (response == null || response.isBlank()) return null; + if (response == null || response.isBlank()) + return null; String s = response.trim(); - if (s.startsWith("```json")) s = s.substring(7); - else if (s.startsWith("```")) s = s.substring(3); - if (s.endsWith("```")) s = s.substring(0, s.length() - 3); + if (s.startsWith("```json")) + s = s.substring(7); + else if (s.startsWith("```")) + s = s.substring(3); + if (s.endsWith("```")) + s = s.substring(0, s.length() - 3); s = s.trim(); int first = s.indexOf('{'); - int last = s.lastIndexOf('}'); + int last = s.lastIndexOf('}'); return (first >= 0 && last > first) ? s.substring(first, last + 1) : null; } private void addStatusHistory(MarketingStrategy strategy, String status, String message) { - if (strategy.getStatusHistory() == null) strategy.setStatusHistory(new ArrayList<>()); + if (strategy.getStatusHistory() == null) + strategy.setStatusHistory(new ArrayList<>()); strategy.getStatusHistory().add(new StatusHistoryEntry(status, LocalDateTime.now(), message)); } @@ -1216,7 +1540,8 @@ public class MarketingStrategyV3Service { if (fbPost.getVideoFilename() != null && !fbPost.getVideoFilename().isEmpty()) { try (java.io.InputStream videoStream = minIOService.downloadFile(fbPost.getVideoFilename())) { videoData = videoStream.readAllBytes(); - log.info("[Facebook] Загружено видео из MinIO: {} ({} bytes)", fbPost.getVideoFilename(), videoData.length); + log.info("[Facebook] Загружено видео из MinIO: {} ({} bytes)", fbPost.getVideoFilename(), + videoData.length); } catch (Exception e) { log.warn("[Facebook] Не удалось загрузить видео {}: {}", fbPost.getVideoFilename(), e.getMessage()); } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index d8f4098..273138d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -144,8 +144,8 @@ facebook.page.id=918835061309575 facebook.page.token=EAAaocqgT3JoBROtsPgmyXxbOCrhL1o6q43rdbXfZCxsrRTRFG4BFrndpZB1SeC77oI3iDqTws6ZBY3Kvv1ZAoZCFubulQjZAItIORmzdQZAmR0szuadwdAkvKENlpERtexNxNRfFEVZCWKDYm1px9knYzoJQOeGjOQOMi3oyweLgouC4ZBB3PoGB6Y8xz5Jh9b5nYCBc3 # Facebook Hot Leads Configuration -facebook.leads.page-id=${FACEBOOK_LEADS_PAGE_ID:122096212527158660} -facebook.leads.access-token=${FACEBOOK_LEADS_ACCESS_TOKEN:} +facebook.leads.page-id=${FACEBOOK_LEADS_PAGE_ID:918835061309575} +facebook.leads.access-token=${FACEBOOK_LEADS_ACCESS_TOKEN:EAAaocqgT3JoBROtsPgmyXxbOCrhL1o6q43rdbXfZCxsrRTRFG4BFrndpZB1SeC77oI3iDqTws6ZBY3Kvv1ZAoZCFubulQjZAItIORmzdQZAmR0szuadwdAkvKENlpERtexNxNRfFEVZCWKDYm1px9knYzoJQOeGjOQOMi3oyweLgouC4ZBB3PoGB6Y8xz5Jh9b5nYCBc3} facebook.leads.cron=${FACEBOOK_LEADS_CRON:0 */15 * * * *} facebook.leads.graph-api-version=${FACEBOOK_LEADS_GRAPH_API_VERSION:v19.0} facebook.leads.timeout-ms=${FACEBOOK_LEADS_TIMEOUT_MS:30000}