diff --git a/src/main/java/kz/konturai/parser/controller/MarketingAnalysisV3Controller.java b/src/main/java/kz/konturai/parser/controller/MarketingAnalysisV3Controller.java index 5306afd..50e6953 100644 --- a/src/main/java/kz/konturai/parser/controller/MarketingAnalysisV3Controller.java +++ b/src/main/java/kz/konturai/parser/controller/MarketingAnalysisV3Controller.java @@ -4,20 +4,28 @@ import jakarta.validation.Valid; import kz.konturai.parser.dto.ApiResponse; import kz.konturai.parser.dto.ErrorResponse; import kz.konturai.parser.dto.MarketingAnalysisV3Request; +import kz.konturai.parser.dto.MarketingStrategyRequest; +import kz.konturai.parser.enums.StrategyModel; import kz.konturai.parser.model.MarketingAnalysisV3Document; +import kz.konturai.parser.model.MarketingStrategy; import kz.konturai.parser.service.JwtService; import kz.konturai.parser.service.MarketingAnalysisV3Service; +import kz.konturai.parser.service.MarketingStrategyV3Service; +import kz.konturai.parser.service.MinIOService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.UUID; @RestController @RequestMapping("/api/marketing/v3") @@ -25,7 +33,9 @@ import java.util.Optional; @Slf4j public class MarketingAnalysisV3Controller { - private final MarketingAnalysisV3Service service; + private final MarketingAnalysisV3Service analysisService; + private final MarketingStrategyV3Service strategyService; + private final MinIOService minIOService; private final JwtService jwtService; private String extractUserIdFromHeader(String authHeader) { @@ -40,6 +50,10 @@ public class MarketingAnalysisV3Controller { } } + // ========================================== + // БЛОК 1: АНАЛИЗ (ОПРОСНИК И ПАРСИНГ) + // ========================================== + @PostMapping("/start") public ResponseEntity startAnalysis( @RequestHeader(value = "Authorization", required = false) String authHeader, @@ -49,7 +63,7 @@ public class MarketingAnalysisV3Controller { if (userId == null) return unauthorizedResponse(); try { - String analysisId = service.createAndStartAnalysis(request, userId); + String analysisId = analysisService.createAndStartAnalysis(request, userId); Map responseData = Map.of( "analysisId", analysisId, "message", "Analysis V3 started" @@ -70,7 +84,7 @@ public class MarketingAnalysisV3Controller { if (userId == null) return unauthorizedResponse(); try { - Optional analysisOpt = service.getAnalysisById(id); + Optional analysisOpt = analysisService.getAnalysisById(id); if (analysisOpt.isEmpty()) return notFoundResponse("Анализ не найден"); MarketingAnalysisV3Document analysis = analysisOpt.get(); @@ -91,7 +105,7 @@ public class MarketingAnalysisV3Controller { if (userId == null) return unauthorizedResponse(); try { - List analyses = service.getAllByUser(userId); + List analyses = analysisService.getAllByUser(userId); return ResponseEntity.ok(ApiResponse.success(analyses)); } catch (Exception e) { log.error("Error fetching user analyses V3", e); @@ -99,6 +113,119 @@ public class MarketingAnalysisV3Controller { } } + // ========================================== + // БЛОК 2: СТРАТЕГИЯ (СКОРИНГ, ЛОГОТИПЫ, МЕДИА) + // ========================================== + + /** + * ПРЕВЬЮ СТРАТЕГИИ: Вызывается, когда юзер открывает раздел Стратегии. + * Моментально возвращает идеальную модель на основе ответов опросника. + */ + @GetMapping("/{analysisId}/strategy-preview") + public ResponseEntity previewStrategy( + @RequestHeader(value = "Authorization", required = false) String authHeader, + @PathVariable String analysisId + ) { + String userId = extractUserIdFromHeader(authHeader); + if (userId == null) return unauthorizedResponse(); + + try { + Optional analysisOpt = analysisService.getAnalysisById(analysisId); + if (analysisOpt.isEmpty()) return notFoundResponse("Анализ не найден"); + + if (!analysisOpt.get().getUserId().equals(userId)) return forbiddenResponse(); + + // Вызываем математику из сервиса напрямую + StrategyModel recommendedModel = strategyService.calculateBestScoringModel(analysisOpt.get().getRequestData()); + + Map responseData = Map.of( + "recommendedModel", recommendedModel.name(), + "modelTitle", recommendedModel.getTitle(), + "description", "На основе ваших ответов ИИ рекомендует эту стратегию для максимального результата." + ); + + return ResponseEntity.ok(ApiResponse.success("Рекомендация сформирована", responseData)); + } catch (Exception e) { + log.error("Error previewing strategy for analysis: {}", analysisId, e); + return internalErrorResponse(e); + } + } + + /** + * ГЕНЕРАЦИЯ СТРАТЕГИИ: Принимает настройки и файл логотипа (опционально) + */ + @PostMapping(value = "/{analysisId}/strategy/generate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity generateStrategy( + @RequestHeader(value = "Authorization", required = false) String authHeader, + @PathVariable String analysisId, + @RequestPart("request") @Valid MarketingStrategyRequest request, + @RequestPart(value = "logo", required = false) MultipartFile logo + ) { + String userId = extractUserIdFromHeader(authHeader); + if (userId == null) return unauthorizedResponse(); + + try { + // Проверяем доступ к анализу + Optional analysisOpt = analysisService.getAnalysisById(analysisId); + if (analysisOpt.isEmpty()) return notFoundResponse("Анализ не найден"); + if (!analysisOpt.get().getUserId().equals(userId)) return forbiddenResponse(); + + String logoFilename = null; + // Если юзер прикрепил логотип, сохраняем его в MinIO + if (logo != null && !logo.isEmpty()) { + String originalExt = logo.getOriginalFilename() != null ? + logo.getOriginalFilename().substring(logo.getOriginalFilename().lastIndexOf(".")) : ".png"; + logoFilename = "logo_" + UUID.randomUUID() + originalExt; + minIOService.uploadFile(logoFilename, logo.getBytes(), logo.getContentType()); + log.info("Логотип клиента успешно загружен в MinIO: {}", logoFilename); + } + + // Запускаем полную генерацию + MarketingStrategy strategy = strategyService.generateStrategy(analysisId, request, userId, logoFilename); + + Map responseData = Map.of( + "strategyId", strategy.getId(), + "status", strategy.getStatus(), + "message", "Генерация стратегии и медиафайлов успешно запущена" + ); + return ResponseEntity.accepted().body(ApiResponse.success("Запущено", responseData)); + + } catch (Exception e) { + log.error("Failed to generate strategy for analysis: {}", analysisId, e); + return internalErrorResponse(e); + } + } + + /** + * ПОЛУЧЕНИЕ СТРАТЕГИИ: Для поллинга (проверки статуса) и отображения готового плана + */ + @GetMapping("/strategy/{strategyId}") + public ResponseEntity getStrategyById( + @RequestHeader(value = "Authorization", required = false) String authHeader, + @PathVariable String strategyId + ) { + String userId = extractUserIdFromHeader(authHeader); + if (userId == null) return unauthorizedResponse(); + + try { + // Предполагается, что в MarketingStrategyV3Service есть метод getStrategyById + Optional strategyOpt = strategyService.getStrategyById(strategyId); + if (strategyOpt.isEmpty()) return notFoundResponse("Стратегия не найдена"); + + MarketingStrategy strategy = strategyOpt.get(); + if (!strategy.getUserId().equals(userId)) return forbiddenResponse(); + + return ResponseEntity.ok(ApiResponse.success(strategy)); + } catch (Exception e) { + log.error("Error fetching strategy: {}", strategyId, e); + return internalErrorResponse(e); + } + } + + // ========================================== + // БЛОК 3: ОБРАБОТКА ОШИБОК + // ========================================== + @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity> handleValidationException(MethodArgumentNotValidException ex) { Map details = new HashMap<>(); diff --git a/src/main/java/kz/konturai/parser/enums/StrategyModel.java b/src/main/java/kz/konturai/parser/enums/StrategyModel.java new file mode 100644 index 0000000..a6f5d62 --- /dev/null +++ b/src/main/java/kz/konturai/parser/enums/StrategyModel.java @@ -0,0 +1,19 @@ +package kz.konturai.parser.enums; + +public enum StrategyModel { + ENTRY("Модель Входа (Entry)", "Много объясняющего (30%) и демо (25%). Темы: 'Как мы работаем', 'Что это за продукт'."), + AUTHORITY("Экспертная модель (Authority)", "Экспертный контент (40%) и кейсы (25%). Темы: 'Разбор кейса', 'Аналитика'."), + TRUST("Модель Доверия (Trust)", "Кейсы (30%) и отзывы (25%). Темы: 'Отзывы клиентов', 'Процесс изнутри'."), + CONVERSION("Конверсионная модель (Conversion)", "Продающий (35%) и демо (30%). Темы: 'Акция', 'Ограниченное предложение'."); + + private final String title; + private final String contentRules; + + StrategyModel(String title, String contentRules) { + this.title = title; + this.contentRules = contentRules; + } + + public String getTitle() { return title; } + public String getContentRules() { return contentRules; } +} \ No newline at end of file diff --git a/src/main/java/kz/konturai/parser/model/MarketingStrategy.java b/src/main/java/kz/konturai/parser/model/MarketingStrategy.java index 6a0b954..ba2e284 100644 --- a/src/main/java/kz/konturai/parser/model/MarketingStrategy.java +++ b/src/main/java/kz/konturai/parser/model/MarketingStrategy.java @@ -1,6 +1,7 @@ package kz.konturai.parser.model; import kz.konturai.parser.dto.StatusHistoryEntry; +import lombok.*; import org.springframework.data.annotation.Id; import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Field; @@ -10,6 +11,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +@Data @Document(collection = "marketing_strategy") public class MarketingStrategy { @@ -41,7 +43,7 @@ public class MarketingStrategy { private LocalDateTime completedAt; @Field("strategy_data") - private Map strategyData; // JSON data with full strategy content + private Map strategyData; @Field("user_id") private String userId; @@ -60,102 +62,9 @@ public class MarketingStrategy { this.analysisId = analysisId; } - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public String getAnalysisId() { - return analysisId; - } - - public void setAnalysisId(String analysisId) { - this.analysisId = analysisId; - } - - public String getStatus() { - return status; - } - - public void setStatus(String status) { - this.status = status; - } - - public Integer getDurationWeeks() { - return durationWeeks; - } - - public void setDurationWeeks(Integer durationWeeks) { - this.durationWeeks = durationWeeks; - } - - public List getPriorityPlatforms() { - return priorityPlatforms; - } - - public void setPriorityPlatforms(List priorityPlatforms) { - this.priorityPlatforms = priorityPlatforms; - } - - public List getWeeklyPlans() { - return weeklyPlans; - } - - public void setWeeklyPlans(List weeklyPlans) { - this.weeklyPlans = weeklyPlans; - } - - public List getPostCalendar() { - return postCalendar; - } - - public void setPostCalendar(List postCalendar) { - this.postCalendar = postCalendar; - } - - public LocalDateTime getCreatedAt() { - return createdAt; - } - - public void setCreatedAt(LocalDateTime createdAt) { - this.createdAt = createdAt; - } - - public LocalDateTime getCompletedAt() { - return completedAt; - } - - public void setCompletedAt(LocalDateTime completedAt) { - this.completedAt = completedAt; - } - - public Map getStrategyData() { - return strategyData; - } - - public void setStrategyData(Map strategyData) { - this.strategyData = strategyData; - } - - public String getUserId() { - return userId; - } - - public void setUserId(String userId) { - this.userId = userId; - } - - public List getStatusHistory() { - return statusHistory; - } - - public void setStatusHistory(List statusHistory) { - this.statusHistory = statusHistory; - } - + @Data + @AllArgsConstructor + @NoArgsConstructor public static class WeeklyPlan { @Field("week_number") private Integer weekNumber; @@ -168,50 +77,11 @@ public class MarketingStrategy { @Field("priority_platforms") private List priorityPlatforms; - - public WeeklyPlan() { - } - - public WeeklyPlan(Integer weekNumber, List mainThemes, String contentRecommendations, List priorityPlatforms) { - this.weekNumber = weekNumber; - this.mainThemes = mainThemes; - this.contentRecommendations = contentRecommendations; - this.priorityPlatforms = priorityPlatforms; - } - - public Integer getWeekNumber() { - return weekNumber; - } - - public void setWeekNumber(Integer weekNumber) { - this.weekNumber = weekNumber; - } - - public List getMainThemes() { - return mainThemes; - } - - public void setMainThemes(List mainThemes) { - this.mainThemes = mainThemes; - } - - public String getContentRecommendations() { - return contentRecommendations; - } - - public void setContentRecommendations(String contentRecommendations) { - this.contentRecommendations = contentRecommendations; - } - - public List getPriorityPlatforms() { - return priorityPlatforms; - } - - public void setPriorityPlatforms(List priorityPlatforms) { - this.priorityPlatforms = priorityPlatforms; - } } + @Data + @AllArgsConstructor + @NoArgsConstructor public static class PostCalendarItem { @Field("publish_date") private LocalDateTime publishDate; @@ -220,7 +90,7 @@ public class MarketingStrategy { private String platform; @Field("content_type") - private String contentType; // пост, сторис, видео, баннер + private String contentType; @Field("theme") private String theme; @@ -232,7 +102,7 @@ public class MarketingStrategy { private List hashtags; @Field("publish_time") - private String publishTime; // время публикации в формате HH:mm + private String publishTime; @Field("image_url") private String imageUrl; @@ -240,90 +110,13 @@ public class MarketingStrategy { @Field("image_filename") private String imageFilename; - public PostCalendarItem() { - } + @Field("video_url") + private String videoUrl; - public PostCalendarItem(LocalDateTime publishDate, String platform, String contentType, String theme, String postText, List hashtags, String publishTime) { - this.publishDate = publishDate; - this.platform = platform; - this.contentType = contentType; - this.theme = theme; - this.postText = postText; - this.hashtags = hashtags; - this.publishTime = publishTime; - } + @Field("video_filename") + private String videoFilename; - public LocalDateTime getPublishDate() { - return publishDate; - } - - public void setPublishDate(LocalDateTime publishDate) { - this.publishDate = publishDate; - } - - public String getPlatform() { - return platform; - } - - public void setPlatform(String platform) { - this.platform = platform; - } - - public String getContentType() { - return contentType; - } - - public void setContentType(String contentType) { - this.contentType = contentType; - } - - public String getTheme() { - return theme; - } - - public void setTheme(String theme) { - this.theme = theme; - } - - public String getPostText() { - return postText; - } - - public void setPostText(String postText) { - this.postText = postText; - } - - public List getHashtags() { - return hashtags; - } - - public void setHashtags(List hashtags) { - this.hashtags = hashtags; - } - - public String getPublishTime() { - return publishTime; - } - - public void setPublishTime(String publishTime) { - this.publishTime = publishTime; - } - - public String getImageUrl() { - return imageUrl; - } - - public void setImageUrl(String imageUrl) { - this.imageUrl = imageUrl; - } - - public String getImageFilename() { - return imageFilename; - } - - public void setImageFilename(String imageFilename) { - this.imageFilename = imageFilename; - } + @Field("video_generation_prompt") + private String videoGenerationPrompt; } -} - +} \ No newline at end of file diff --git a/src/main/java/kz/konturai/parser/service/GeminiVideoGenerationService.java b/src/main/java/kz/konturai/parser/service/GeminiVideoGenerationService.java new file mode 100644 index 0000000..44ab376 --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/GeminiVideoGenerationService.java @@ -0,0 +1,59 @@ +package kz.konturai.parser.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.HashMap; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Slf4j +public class GeminiVideoGenerationService { + + private final RestTemplate restTemplate = new RestTemplate(); // Можно заинжектить через @Bean, если есть + + @Value("${gemini.veo.api.url:https://generativelanguage.googleapis.com/v1beta/models/veo:generateVideo}") + private String videoApiUrl; + + @Value("${gemini.veo.api.key:YOUR_API_KEY}") + private String apiKey; + + public byte[] generateVideo(String prompt) { + log.info("Запуск генерации видео через Gemini Veo. Промпт: {}", prompt); + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("x-goog-api-key", apiKey); + + Map requestBody = new HashMap<>(); + requestBody.put("prompt", prompt); + requestBody.put("aspectRatio", "9:16"); + requestBody.put("resolution", "1080p"); + + HttpEntity> entity = new HttpEntity<>(requestBody, headers); + + ResponseEntity response = restTemplate.exchange( + videoApiUrl, + HttpMethod.POST, + entity, + byte[].class + ); + + if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) { + log.info("Видео успешно сгенерировано. Размер: {} байт", response.getBody().length); + return response.getBody(); + } else { + log.error("Ошибка API генерации видео. Код: {}", response.getStatusCode()); + return null; + } + } catch (Exception e) { + log.error("Критическая ошибка при генерации видео: {}", e.getMessage(), e); + return null; + } + } +} \ No newline at end of file diff --git a/src/main/java/kz/konturai/parser/service/ImageGenerationService.java b/src/main/java/kz/konturai/parser/service/ImageGenerationService.java index bc47d6a..90ec6b7 100644 --- a/src/main/java/kz/konturai/parser/service/ImageGenerationService.java +++ b/src/main/java/kz/konturai/parser/service/ImageGenerationService.java @@ -13,5 +13,7 @@ public interface ImageGenerationService { * @return Массив байтов изображения в формате PNG, или null в случае ошибки */ byte[] generateImage(String prompt); + + byte[] generateImageWithReference(String prompt, byte[] referenceImageBytes); } diff --git a/src/main/java/kz/konturai/parser/service/MarketingStrategyV3Service.java b/src/main/java/kz/konturai/parser/service/MarketingStrategyV3Service.java new file mode 100644 index 0000000..6338e9d --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/MarketingStrategyV3Service.java @@ -0,0 +1,361 @@ +package kz.konturai.parser.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import kz.konturai.parser.dto.MarketingStrategyRequest; +import kz.konturai.parser.dto.MarketingAnalysisV3Request; +import kz.konturai.parser.dto.StatusHistoryEntry; +import kz.konturai.parser.enums.StrategyModel; +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 lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.io.InputStream; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@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 MinIOService minIOService; + + private final ObjectMapper objectMapper = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + @Value("${openai.model.name.text:gpt-4o}") + private String highIntelligenceModel; + + @Value("${image.generation.delayBetweenRequestsMs:3000}") + private long delayBetweenRequestsMs; + + // ДОБАВЛЕННЫЙ МЕТОД, КОТОРЫЙ ИСКАЛ КОНТРОЛЛЕР + public Optional getStrategyById(String id) { + return repository.findById(id); + } + + public MarketingStrategy generateStrategy(String analysisId, MarketingStrategyRequest request, String userId, String uploadedLogoFilename) { + MarketingAnalysisV3Document analysisDoc = analysisRepository.findById(analysisId) + .orElseThrow(() -> new IllegalArgumentException("Анализ V3 не найден")); + + MarketingStrategy strategy = new MarketingStrategy(analysisId); + strategy.setUserId(userId); + strategy.setDurationWeeks(request.getDurationWeeks() != null ? request.getDurationWeeks() : 4); + strategy.setPriorityPlatforms(request.getPriorityPlatforms()); + strategy.setStatus("queued"); + + // Сохраняем имя файла логотипа в strategyData, чтобы асинхронный процесс мог его скачать + Map initialData = new HashMap<>(); + if (uploadedLogoFilename != null && !uploadedLogoFilename.isEmpty()) { + initialData.put("clientLogoFilename", uploadedLogoFilename); + } + strategy.setStrategyData(initialData); + + addStatusHistoryEntry(strategy, "queued", "Запуск генерации стратегии"); + + strategy = repository.save(strategy); + processStrategyGenerationAsync(strategy.getId(), analysisDoc, strategy); + + return strategy; + } + + @Async("reportGenerationExecutor") + public void processStrategyGenerationAsync(String strategyId, MarketingAnalysisV3Document analysis, MarketingStrategy strategy) { + try { + strategy.setStatus("processing"); + addStatusHistoryEntry(strategy, "processing", "Расчет баллов и генерация глубокого контент-плана..."); + repository.save(strategy); + + StrategyModel bestModel = calculateBestScoringModel(analysis.getRequestData()); + log.info("[Strategy ID: {}] Выбрана модель: {}", strategyId, bestModel.name()); + + String systemPrompt = buildSystemPrompt(bestModel); + String userPrompt = buildUserPrompt(analysis, strategy, bestModel); + + String rawResponse = aiService.generateWithInstructionWithModel("{}", userPrompt, "ru", highIntelligenceModel, systemPrompt, 16000, 240000L); + String jsonResponse = extractCleanJson(rawResponse); + + Map strategyData = objectMapper.readValue(jsonResponse, new TypeReference<>() {}); + + // Сохраняем старые данные (например, clientLogoFilename) перед перезаписью + if (strategy.getStrategyData() != null && strategy.getStrategyData().containsKey("clientLogoFilename")) { + strategyData.put("clientLogoFilename", strategy.getStrategyData().get("clientLogoFilename")); + } + + populateStrategyEntity(strategy, strategyData); + repository.save(strategy); + + generateMediaAssets(strategy, analysis); + + strategy.setStatus("completed"); + strategy.setCompletedAt(LocalDateTime.now()); + addStatusHistoryEntry(strategy, "completed", "Стратегия и все медиа успешно созданы"); + repository.save(strategy); + + } catch (Exception e) { + log.error("Ошибка при генерации стратегии {}: {}", strategyId, e.getMessage(), e); + markAsFailed(strategyId, e.getMessage()); + } + } + + // ТЕПЕРЬ PUBLIC, ЧТОБЫ КОНТРОЛЛЕР МОГ ЕГО ВЫЗВАТЬ ДЛЯ ПРЕВЬЮ + public StrategyModel calculateBestScoringModel(MarketingAnalysisV3Request req) { + int entry = 0, authority = 0, trust = 0, conversion = 0; + + if (req.getCustomerBehaviors() != null) { + for (var cb : req.getCustomerBehaviors()) { + String name = cb.name(); + if (name.contains("FAST") || name.contains("IMPULSE")) { conversion += 3; } + else if (name.contains("COMPARE")) { authority += 2; trust += 1; } + else if (name.contains("CONSULT")) { authority += 3; } + else if (name.contains("CASE") || name.contains("REVIEW")) { trust += 3; } + } + } + + if (req.getAverageCheck() != null) { + String check = req.getAverageCheck().name(); + if (check.contains("LOW")) { conversion += 2; } + else if (check.contains("MID")) { authority += 1; trust += 1; conversion += 1; } + else if (check.contains("HIGH")) { authority += 2; trust += 1; } + } + + if (req.getBusinessStage() != null) { + String stage = req.getBusinessStage().name(); + if (stage.contains("LAUNCH")) { entry += 3; } + else if (stage.contains("LESS")) { entry += 2; authority += 1; } + else { authority += 1; trust += 1; conversion += 1; } + } + + if (req.getClientTarget() != null) { + String target = req.getClientTarget().name(); + if (target.contains("B2B")) { authority += 2; trust += 1; } + else if (target.contains("B2C")) { trust += 1; conversion += 2; } + else { authority += 1; trust += 1; conversion += 1; } + } + + int max = Math.max(Math.max(entry, authority), Math.max(trust, conversion)); + + if (max == authority && max == trust && req.getAverageCheck() != null && req.getAverageCheck().name().contains("HIGH")) return StrategyModel.AUTHORITY; + if (max == trust && max == conversion) return StrategyModel.TRUST; + if (max == entry && req.getBusinessStage() != null && req.getBusinessStage().name().contains("LAUNCH")) return StrategyModel.ENTRY; + + if (max == conversion) return StrategyModel.CONVERSION; + if (max == trust) return StrategyModel.TRUST; + if (max == authority) return StrategyModel.AUTHORITY; + return StrategyModel.ENTRY; + } + + @SuppressWarnings("unchecked") + private void populateStrategyEntity(MarketingStrategy strategy, Map strategyData) { + strategy.setStrategyData(strategyData); + + List> weeksMap = (List>) strategyData.get("weeklyPlans"); + List weeklyPlans = new ArrayList<>(); + if (weeksMap != null) { + for (Map w : weeksMap) { + MarketingStrategy.WeeklyPlan plan = new MarketingStrategy.WeeklyPlan(); + plan.setWeekNumber((Integer) w.get("weekNumber")); + plan.setMainThemes((List) w.get("mainThemes")); + plan.setContentRecommendations((String) w.get("contentRecommendations")); + plan.setPriorityPlatforms((List) w.get("priorityPlatforms")); + weeklyPlans.add(plan); + } + } + strategy.setWeeklyPlans(weeklyPlans); + + List> calendarMap = (List>) strategyData.get("postCalendar"); + List postCalendar = new ArrayList<>(); + if (calendarMap != null) { + for (Map c : calendarMap) { + MarketingStrategy.PostCalendarItem item = new MarketingStrategy.PostCalendarItem(); + try { + item.setPublishDate(LocalDateTime.parse((String) c.get("publishDate"), DateTimeFormatter.ISO_LOCAL_DATE_TIME)); + } catch (Exception e) { + item.setPublishDate(LocalDateTime.now().plusDays(postCalendar.size() + 1)); + } + item.setPlatform((String) c.get("platform")); + item.setContentType((String) c.get("contentType")); + item.setTheme((String) c.get("theme")); + item.setPostText((String) c.get("postText")); + item.setHashtags((List) c.get("hashtags")); + item.setPublishTime((String) c.get("publishTime")); + + if (c.containsKey("videoGenerationPrompt")) { + item.setVideoGenerationPrompt((String) c.get("videoGenerationPrompt")); + } + if (c.containsKey("imageGenerationPrompt")) { + item.setImageFilename((String) c.get("imageGenerationPrompt")); + } + + postCalendar.add(item); + } + } + strategy.setPostCalendar(postCalendar); + } + + private void generateMediaAssets(MarketingStrategy strategy, MarketingAnalysisV3Document analysis) { + String businessContext = extractBusinessContext(analysis); + + // 1. Загружаем логотип клиента, если он был передан + byte[] clientLogoBytes = null; + if (strategy.getStrategyData() != null && strategy.getStrategyData().containsKey("clientLogoFilename")) { + String logoFilename = (String) strategy.getStrategyData().get("clientLogoFilename"); + try { + InputStream logoStream = minIOService.downloadFile(logoFilename); + clientLogoBytes = logoStream.readAllBytes(); + logoStream.close(); + log.info("Успешно загружен референсный логотип клиента для интеграции: {}", logoFilename); + } catch (Exception e) { + log.error("Не удалось скачать логотип клиента из MinIO {}: {}", logoFilename, e.getMessage()); + } + } + + for (MarketingStrategy.PostCalendarItem item : strategy.getPostCalendar()) { + String contentType = item.getContentType() != null ? item.getContentType().toLowerCase() : ""; + + try { + if (contentType.contains("видео") || contentType.contains("reels") || contentType.contains("tiktok")) { + String videoPrompt = item.getVideoGenerationPrompt(); + if (videoPrompt != null && !videoPrompt.isEmpty()) { + byte[] videoBytes = geminiVideoService.generateVideo(videoPrompt); + if (videoBytes != null && videoBytes.length > 0) { + String filename = "video_" + System.currentTimeMillis() + "_" + item.hashCode() + ".mp4"; + minIOService.uploadFile(filename, videoBytes, "video/mp4"); + item.setVideoUrl(filename); + item.setVideoFilename(filename); + } + } + } else { + String imagePrompt = item.getImageFilename(); + if (imagePrompt != null && !imagePrompt.isEmpty()) { + + // 2. ВЫЗЫВАЕМ НОВЫЙ МЕТОД С РЕФЕРЕНСОМ ЛОГОТИПА + byte[] imageBytes = imageGenerationService.generateImageWithReference(imagePrompt, clientLogoBytes); + + if (imageBytes != null && imageBytes.length > 0) { + String filename = "image_" + System.currentTimeMillis() + "_" + item.hashCode() + ".png"; + minIOService.uploadFile(filename, imageBytes, MediaType.IMAGE_PNG.toString()); + item.setImageUrl(filename); + item.setImageFilename(filename); + } + } + } + if (delayBetweenRequestsMs > 0) Thread.sleep(delayBetweenRequestsMs); + } catch (Exception e) { + log.error("Ошибка создания медиа для поста '{}': {}", item.getTheme(), e.getMessage()); + } + } + } + + private String buildSystemPrompt(StrategyModel bestModel) { + return """ + РОЛЬ: Ты — топовый Голливудский Креативный Директор и SMM Стратег. + Бэкенд-система произвела расчет и выбрала доминирующую стратегию: %s. + + Твоя задача — сгенерировать глубокий, продуманный контент-план, строго соблюдая правила этой модели: + %s + + КРИТИЧЕСКИЕ ПРАВИЛА ГЕНЕРАЦИИ МЕДИА-ПРОМПТОВ: + 1. Если contentType = 'фото' или 'баннер', ОБЯЗАТЕЛЬНО добавь поле 'imageGenerationPrompt' (на английском языке). + Опиши сцену (высшее качество, 8k, фотореализм). + ИНТЕГРАЦИЯ БРЕНДА: Нативно впиши название бренда или продукта в физический мир картинки! + ИИ должен сам решить, куда органично вписать логотип (на кружку, футболку, ноутбук, билборд). + + 2. Если contentType = 'видео' или 'reels', ОБЯЗАТЕЛЬНО добавь поле 'videoGenerationPrompt' (на английском языке). + Опиши режиссерский сценарий для нейросети генерации видео (движение камеры, свет, объекты). + + ВЫВОДИ ТОЛЬКО ЧИСТЫЙ JSON ПОСЛЕ БЛОКА ! + """.formatted(bestModel.getTitle(), bestModel.getContentRules()); + } + + private String buildUserPrompt(MarketingAnalysisV3Document analysis, MarketingStrategy strategy, StrategyModel bestModel) throws Exception { + String clientDtoJson = objectMapper.writeValueAsString(analysis.getRequestData()); + String platforms = String.join(", ", strategy.getPriorityPlatforms()); + LocalDateTime startDate = LocalDateTime.now().plusDays(1); + String brandName = analysis.getRequestData().getProductName(); + + return """ + ДАННЫЕ БИЗНЕСА: + %s + + Настройки: %d недель. Платформы: %s. Старт: %s + Бренд клиента: "%s" (Обязательно используй это имя для интеграции в 'imageGenerationPrompt'!). + ВЫБРАННАЯ МОДЕЛЬ: %s + + ВЫВЕДИ ТОЛЬКО JSON: + { + "selectedModel": "%s", + "rationale": "Детальное бизнес-обоснование", + "weeklyPlans": [ + { + "weekNumber": 1, + "mainThemes": ["Тема 1", "Тема 2"], + "contentRecommendations": "Что снимать и писать", + "priorityPlatforms": ["Instagram"] + } + ], + "postCalendar": [ + { + "publishDate": "2024-01-15T10:00:00", + "platform": "Instagram", + "contentType": "фото", + "theme": "Тема поста", + "postText": "Глубокий текст поста, готовый к публикации...", + "hashtags": ["#тег"], + "publishTime": "10:00", + "imageGenerationPrompt": "Подробный промпт на английском. Пример: A high-end lifestyle shot of a workspace. The brand name '%s' is subtly engraved on the premium leather notebook lying on the table. Photorealistic, 8k.", + "videoGenerationPrompt": null + } + ] + } + """.formatted(clientDtoJson, strategy.getDurationWeeks(), platforms, startDate.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), brandName, bestModel.name(), bestModel.name(), brandName); + } + + private String extractCleanJson(String rawResponse) { + if (rawResponse == null || rawResponse.trim().isEmpty()) return null; + String cleaned = rawResponse.trim(); + int scratchpadEnd = cleaned.indexOf(""); + if (scratchpadEnd != -1) cleaned = cleaned.substring(scratchpadEnd + "".length()).trim(); + int firstBrace = cleaned.indexOf("{"); + int lastBrace = cleaned.lastIndexOf("}"); + if (firstBrace != -1 && lastBrace != -1 && firstBrace <= lastBrace) return cleaned.substring(firstBrace, lastBrace + 1); + return cleaned; + } + + private String extractBusinessContext(MarketingAnalysisV3Document analysis) { + if (analysis == null || analysis.getRequestData() == null) return ""; + return "Ниша: " + analysis.getRequestData().getBusinessNiche() + ", Продукт: " + analysis.getRequestData().getProductName(); + } + + private void addStatusHistoryEntry(MarketingStrategy strategy, String status, String message) { + if (strategy.getStatusHistory() == null) strategy.setStatusHistory(new ArrayList<>()); + strategy.getStatusHistory().add(new StatusHistoryEntry(status, LocalDateTime.now(), message)); + } + + private void markAsFailed(String strategyId, String errorMsg) { + repository.findById(strategyId).ifPresent(s -> { + s.setStatus("failed"); + addStatusHistoryEntry(s, "failed", errorMsg); + repository.save(s); + }); + } +} \ No newline at end of file diff --git a/src/main/java/kz/konturai/parser/service/NanoBananaImageGenerationService.java b/src/main/java/kz/konturai/parser/service/NanoBananaImageGenerationService.java index f1c9cf9..e471fa5 100644 --- a/src/main/java/kz/konturai/parser/service/NanoBananaImageGenerationService.java +++ b/src/main/java/kz/konturai/parser/service/NanoBananaImageGenerationService.java @@ -9,6 +9,7 @@ import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.io.ClassPathResource; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; import org.springframework.web.reactive.function.client.ExchangeStrategies; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClientResponseException; @@ -66,8 +67,27 @@ public class NanoBananaImageGenerationService implements ImageGenerationService .build(); } + // Стандартный метод (если логотипа нет) @Override public byte[] generateImage(String prompt) { + return generateImageWithReference(prompt, (byte[]) null); + } + + // НОВЫЙ МЕТОД: Принимает MultipartFile от контроллера + public byte[] generateImage(String prompt, MultipartFile referenceLogo) { + if (referenceLogo == null || referenceLogo.isEmpty()) { + return generateImage(prompt); + } + try { + return generateImageWithReference(prompt, referenceLogo.getBytes()); + } catch (IOException e) { + logger.error("Ошибка при чтении MultipartFile логотипа: {}", e.getMessage()); + return generateImage(prompt); // Fallback на обычную генерацию, если файл битый + } + } + + // Основная логика генерации с поддержкой референсного изображения (логотипа) + public byte[] generateImageWithReference(String prompt, byte[] referenceImageBytes) { if (projectId == null || projectId.trim().isEmpty()) { logger.error("Project ID is missing! Check application.properties"); return null; @@ -88,15 +108,16 @@ public class NanoBananaImageGenerationService implements ImageGenerationService String endpointUrl = String.format(VERTEX_API_TEMPLATE, location, projectId, location, model); - String finalPrompt = prompt; if (prompt != null && prompt.length() < 50) { finalPrompt = "High quality, photorealistic image of: " + prompt; } - Map requestBody = buildImagenRequestBody(finalPrompt); + // Передаем байты картинки в сборщик JSON + Map requestBody = buildImagenRequestBody(finalPrompt, referenceImageBytes); - logger.info("Sending request to Vertex AI. Project: {}, Model: {}", projectId, model); + logger.info("Sending request to Vertex AI. Project: {}, Model: {}. With Logo: {}", + projectId, model, (referenceImageBytes != null)); Map response = webClient.post() .uri(URI.create(endpointUrl)) @@ -145,22 +166,41 @@ public class NanoBananaImageGenerationService implements ImageGenerationService } } - private Map buildImagenRequestBody(String prompt) { + // ОБНОВЛЕННЫЙ БИЛДЕР: Вшивает логотип в JSON запрос + private Map buildImagenRequestBody(String prompt, byte[] referenceImageBytes) { Map body = new HashMap<>(); List> instances = new ArrayList<>(); Map instance = new HashMap<>(); + instance.put("prompt", prompt); + + // Если передали логотип — добавляем его как SUBJECT REFERENCE + if (referenceImageBytes != null && referenceImageBytes.length > 0) { + String base64Image = Base64.getEncoder().encodeToString(referenceImageBytes); + + Map referenceImageParams = new HashMap<>(); + Map imageBytesMap = new HashMap<>(); + + imageBytesMap.put("bytesBase64Encoded", base64Image); + referenceImageParams.put("referenceImage", imageBytesMap); + // SUBJECT - заставляет ИИ интегрировать этот объект в сцену + referenceImageParams.put("referenceType", "SUBJECT"); + + List> refImagesList = new ArrayList<>(); + refImagesList.add(referenceImageParams); + + instance.put("referenceImages", refImagesList); + } + instances.add(instance); body.put("instances", instances); Map parameters = new HashMap<>(); parameters.put("sampleCount", 1); - parameters.put("aspectRatio", "1:1"); - + parameters.put("aspectRatio", "1:1"); // Можно вынести в настройки, если нужны сторисы 9:16 parameters.put("safetyFilterLevel", "block_some"); parameters.put("personGeneration", "allow_adult"); - - parameters.put("negativePrompt", "nsfw, nudity, sexual content, lgbt symbols, rainbow flags, provocative clothing, violence, gore, blood, deformed, ugly, watermark, text, signature, low quality, blurry, distorted, unrealistic"); + parameters.put("negativePrompt", "nsfw, nudity, sexual content, lgbt symbols, rainbow flags, provocative clothing, violence, gore, blood, deformed, ugly, watermark, signature, low quality, blurry, distorted, unrealistic"); body.put("parameters", parameters); return body; diff --git a/src/main/java/kz/konturai/parser/service/OpenAIImageGenerationService.java b/src/main/java/kz/konturai/parser/service/OpenAIImageGenerationService.java index 804277f..eac4ae5 100644 --- a/src/main/java/kz/konturai/parser/service/OpenAIImageGenerationService.java +++ b/src/main/java/kz/konturai/parser/service/OpenAIImageGenerationService.java @@ -85,6 +85,11 @@ public class OpenAIImageGenerationService implements ImageGenerationService { return generateImage(prompt, imageSize); } + @Override + public byte[] generateImageWithReference(String prompt, byte[] referenceImageBytes) { + return new byte[0]; + } + /** * Генерирует изображение через OpenAI DALL-E API с указанным размером *