diff --git a/src/main/java/kz/konturai/parser/controller/MarketingAnalysisV3Controller.java b/src/main/java/kz/konturai/parser/controller/MarketingAnalysisV3Controller.java index fd17eb5..588dbcc 100644 --- a/src/main/java/kz/konturai/parser/controller/MarketingAnalysisV3Controller.java +++ b/src/main/java/kz/konturai/parser/controller/MarketingAnalysisV3Controller.java @@ -2,7 +2,6 @@ package kz.konturai.parser.controller; import jakarta.validation.Valid; import kz.konturai.parser.dto.*; -import kz.konturai.parser.enums.StrategyModel; import kz.konturai.parser.model.MarketingAnalysisV3Document; import kz.konturai.parser.model.MarketingStrategy; import kz.konturai.parser.model.PostingTask; @@ -40,14 +39,12 @@ public class MarketingAnalysisV3Controller { private final MarketingAnalysisV3Service analysisService; private final MarketingStrategyV3Service strategyService; - private final PostingTaskService postingTaskService; - private final MinIOService minIOService; - private final JwtService jwtService; + private final PostingTaskService postingTaskService; + private final MinIOService minIOService; + private final JwtService jwtService; private String extractUserIdFromHeader(String authHeader) { - if (authHeader == null || authHeader.isEmpty()) { - return null; - } + if (authHeader == null || authHeader.isEmpty()) return null; try { return jwtService.extractUserIdFromHeader(authHeader); } catch (Exception e) { @@ -55,21 +52,22 @@ public class MarketingAnalysisV3Controller { } } + // ========================================================= + // ANALYSIS + // ========================================================= + @PostMapping("/start") public ResponseEntity startAnalysis( @RequestHeader(value = "Authorization", required = false) String authHeader, - @RequestBody @Valid MarketingAnalysisV3Request request - ) { + @RequestBody @Valid MarketingAnalysisV3Request request) { + String userId = extractUserIdFromHeader(authHeader); if (userId == null) return unauthorizedResponse(); try { String analysisId = analysisService.createAndStartAnalysis(request, userId); - Map responseData = Map.of( - "analysisId", analysisId, - "message", "Analysis V3 started" - ); - return ResponseEntity.accepted().body(ApiResponse.success("Анализ запущен", responseData)); + return ResponseEntity.accepted().body(ApiResponse.success("Анализ запущен", + Map.of("analysisId", analysisId, "message", "Analysis V3 started"))); } catch (Exception e) { return internalErrorResponse(e); } @@ -78,8 +76,8 @@ public class MarketingAnalysisV3Controller { @GetMapping("/{id}") public ResponseEntity getAnalysisById( @RequestHeader(value = "Authorization", required = false) String authHeader, - @PathVariable String id - ) { + @PathVariable String id) { + String userId = extractUserIdFromHeader(authHeader); if (userId == null) return unauthorizedResponse(); @@ -98,14 +96,13 @@ public class MarketingAnalysisV3Controller { @GetMapping("/my") public ResponseEntity getUserAnalyses( - @RequestHeader(value = "Authorization", required = false) String authHeader - ) { + @RequestHeader(value = "Authorization", required = false) String authHeader) { + String userId = extractUserIdFromHeader(authHeader); if (userId == null) return unauthorizedResponse(); try { - List analyses = analysisService.getAllByUser(userId); - return ResponseEntity.ok(ApiResponse.success(analyses)); + return ResponseEntity.ok(ApiResponse.success(analysisService.getAllByUser(userId))); } catch (Exception e) { return internalErrorResponse(e); } @@ -114,8 +111,8 @@ public class MarketingAnalysisV3Controller { @GetMapping("/{analysisId}/history") public ResponseEntity getAnalysisHistory( @RequestHeader(value = "Authorization", required = false) String authHeader, - @PathVariable String analysisId - ) { + @PathVariable String analysisId) { + String userId = extractUserIdFromHeader(authHeader); if (userId == null) return unauthorizedResponse(); @@ -133,7 +130,6 @@ public class MarketingAnalysisV3Controller { response.setProduct(analysis.getRequestData().getProductName()); response.setGoal(analysis.getRequestData().getGoal()); response.setDetailLevel(analysis.getRequestData().getDetailLevel()); - if (analysis.getRequestData().getAnalysisType() != null) { response.setAnalysisType(String.join(", ", analysis.getRequestData().getAnalysisType())); } @@ -158,8 +154,8 @@ public class MarketingAnalysisV3Controller { @GetMapping("/{analysisId}/download") public ResponseEntity downloadPdf( @RequestHeader(value = "Authorization", required = false) String authHeader, - @PathVariable String analysisId - ) { + @PathVariable String analysisId) { + String userId = extractUserIdFromHeader(authHeader); if (userId == null) return unauthorizedResponse(); @@ -178,24 +174,27 @@ public class MarketingAnalysisV3Controller { } } + // ========================================================= + // MEDIA + // ========================================================= + @GetMapping("/images/{imageFilename}") public ResponseEntity getPostImage( @RequestHeader(value = "Authorization", required = false) String authHeader, - @PathVariable String imageFilename - ) { + @PathVariable String imageFilename) { + String userId = extractUserIdFromHeader(authHeader); if (userId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); try { - if (!minIOService.fileExists(imageFilename)) { - return ResponseEntity.notFound().build(); - } - InputStream inputStream = minIOService.downloadFile(imageFilename); - byte[] bytes = inputStream.readAllBytes(); - inputStream.close(); + if (!minIOService.fileExists(imageFilename)) return ResponseEntity.notFound().build(); + + InputStream is = minIOService.downloadFile(imageFilename); + byte[] bytes = is.readAllBytes(); + is.close(); return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_TYPE, MediaType.IMAGE_PNG_VALUE) + .header(HttpHeaders.CONTENT_TYPE, MediaType.IMAGE_PNG_VALUE) .header(HttpHeaders.CACHE_CONTROL, "public, max-age=3600") .body(bytes); } catch (Exception e) { @@ -206,20 +205,19 @@ public class MarketingAnalysisV3Controller { @GetMapping("/videos/{videoFilename}") public ResponseEntity getPostVideo( @RequestHeader(value = "Authorization", required = false) String authHeader, - @PathVariable String videoFilename - ) { + @PathVariable String videoFilename) { + String userId = extractUserIdFromHeader(authHeader); if (userId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); try { - if (!minIOService.fileExists(videoFilename)) { - return ResponseEntity.notFound().build(); - } - InputStream inputStream = minIOService.downloadFile(videoFilename); - InputStreamResource resource = new InputStreamResource(inputStream); + if (!minIOService.fileExists(videoFilename)) return ResponseEntity.notFound().build(); + + InputStream is = minIOService.downloadFile(videoFilename); + InputStreamResource resource = new InputStreamResource(is); return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_TYPE, "video/mp4") + .header(HttpHeaders.CONTENT_TYPE, "video/mp4") .header(HttpHeaders.CACHE_CONTROL, "public, max-age=3600") .body(resource); } catch (Exception e) { @@ -227,26 +225,49 @@ public class MarketingAnalysisV3Controller { } } + // ========================================================= + // STRATEGY — PREVIEW + // Единственное место в контроллере, которое нужно было обновить: + // старый calculateBestScoringModel(req) → новый calculateScoringModel(req, analysis), + // который возвращает ScoringResult вместо StrategyModel. + // Теперь preview показывает фронту и модель, и обоснование, и активные модификаторы. + // ========================================================= + @GetMapping("/{analysisId}/strategy-preview") public ResponseEntity previewStrategy( @RequestHeader(value = "Authorization", required = false) String authHeader, - @PathVariable String analysisId - ) { + @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()); + MarketingAnalysisV3Document analysis = analysisOpt.get(); + if (!analysis.getUserId().equals(userId)) return forbiddenResponse(); - Map responseData = Map.of( - "recommendedModel", recommendedModel.name(), - "modelTitle", recommendedModel.getTitle(), - "description", "ИИ автоматически подберет идеальные платформы и длительность контент-плана для этой стратегии." - ); + // Используем новый метод: передаём и request, и analysis (для CII и рыночной интенсивности) + ScoringResult scoring = strategyService.calculateScoringModel( + analysis.getRequestData(), analysis); + + Map responseData = new HashMap<>(); + responseData.put("recommendedModel", scoring.getModel().name()); + responseData.put("modelTitle", scoring.getModel().getTitle()); + responseData.put("rationale", scoring.getRationale()); + responseData.put("activeModifiers", + scoring.getActiveModifiers().stream() + .map(m -> Map.of("key", m.name(), "title", m.getTitle())) + .collect(Collectors.toList())); + responseData.put("scores", Map.of( + "entry", scoring.getEntryScore(), + "authority", scoring.getAuthorityScore(), + "trust", scoring.getTrustScore(), + "conversion", scoring.getConversionScore() + )); + responseData.put("description", + "ИИ автоматически подберёт платформы и длительность контент-плана для этой стратегии."); return ResponseEntity.ok(ApiResponse.success("Рекомендация сформирована", responseData)); } catch (Exception e) { @@ -254,13 +275,17 @@ public class MarketingAnalysisV3Controller { } } + // ========================================================= + // STRATEGY — GENERATE / GET + // ========================================================= + @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(value = "request", required = false) MarketingStrategyRequest request, - @RequestPart(value = "references", required = false) List referenceFiles - ) { + @RequestPart(value = "request", required = false) MarketingStrategyRequest request, + @RequestPart(value = "references", required = false) List referenceFiles) { + String userId = extractUserIdFromHeader(authHeader); if (userId == null) return unauthorizedResponse(); @@ -269,32 +294,36 @@ public class MarketingAnalysisV3Controller { if (analysisOpt.isEmpty()) return notFoundResponse("Анализ не найден"); if (!analysisOpt.get().getUserId().equals(userId)) return forbiddenResponse(); + // Загружаем референсные файлы (логотипы/стиль клиента) в MinIO List savedReferenceFilenames = new ArrayList<>(); if (referenceFiles != null && !referenceFiles.isEmpty()) { for (MultipartFile file : referenceFiles) { if (!file.isEmpty()) { - String originalExt = file.getOriginalFilename() != null && file.getOriginalFilename().contains(".") ? - file.getOriginalFilename().substring(file.getOriginalFilename().lastIndexOf(".")) : ".png"; - String filename = "ref_" + UUID.randomUUID() + originalExt; + String ext = (file.getOriginalFilename() != null + && file.getOriginalFilename().contains(".")) + ? file.getOriginalFilename().substring( + file.getOriginalFilename().lastIndexOf(".")) + : ".png"; + String filename = "ref_" + UUID.randomUUID() + ext; minIOService.uploadFile(filename, file.getBytes(), file.getContentType()); savedReferenceFilenames.add(filename); + log.info("Uploaded reference file: {}", filename); } } } - if (request == null) { - request = new MarketingStrategyRequest(); - } + if (request == null) request = new MarketingStrategyRequest(); - MarketingStrategy strategy = strategyService.generateStrategy(analysisId, request, userId, savedReferenceFilenames); - - Map responseData = Map.of( - "strategyId", strategy.getId(), - "status", strategy.getStatus(), - "message", "Генерация запущена. ИИ самостоятельно анализирует нишу для выбора лучших платформ и длительности кампании." - ); - return ResponseEntity.accepted().body(ApiResponse.success("Автономная генерация запущена", responseData)); + MarketingStrategy strategy = strategyService.generateStrategy( + analysisId, request, userId, savedReferenceFilenames); + return ResponseEntity.accepted().body(ApiResponse.success( + "Автономная генерация запущена", + Map.of( + "strategyId", strategy.getId(), + "status", strategy.getStatus(), + "message", "Генерация запущена. ИИ анализирует нишу для выбора лучших платформ." + ))); } catch (Exception e) { return internalErrorResponse(e); } @@ -303,8 +332,8 @@ public class MarketingAnalysisV3Controller { @GetMapping("/strategy/{strategyId}") public ResponseEntity getStrategyById( @RequestHeader(value = "Authorization", required = false) String authHeader, - @PathVariable String strategyId - ) { + @PathVariable String strategyId) { + String userId = extractUserIdFromHeader(authHeader); if (userId == null) return unauthorizedResponse(); @@ -327,8 +356,8 @@ public class MarketingAnalysisV3Controller { @GetMapping("/{analysisId}/strategy") public ResponseEntity getStrategyByAnalysis( @RequestHeader(value = "Authorization", required = false) String authHeader, - @PathVariable String analysisId - ) { + @PathVariable String analysisId) { + String userId = extractUserIdFromHeader(authHeader); if (userId == null) return unauthorizedResponse(); @@ -351,8 +380,8 @@ public class MarketingAnalysisV3Controller { @GetMapping("/strategy/my") public ResponseEntity getMyStrategies( - @RequestHeader(value = "Authorization", required = false) String authHeader - ) { + @RequestHeader(value = "Authorization", required = false) String authHeader) { + String userId = extractUserIdFromHeader(authHeader); if (userId == null) return unauthorizedResponse(); @@ -361,7 +390,6 @@ public class MarketingAnalysisV3Controller { List responseList = strategies.stream() .map(this::convertToStrategyHistoryResponse) .collect(Collectors.toList()); - return ResponseEntity.ok(ApiResponse.success(responseList)); } catch (Exception e) { return internalErrorResponse(e); @@ -371,8 +399,8 @@ public class MarketingAnalysisV3Controller { @GetMapping("/strategy/{strategyId}/history") public ResponseEntity getStrategyHistory( @RequestHeader(value = "Authorization", required = false) String authHeader, - @PathVariable String strategyId - ) { + @PathVariable String strategyId) { + String userId = extractUserIdFromHeader(authHeader); if (userId == null) return unauthorizedResponse(); @@ -383,18 +411,21 @@ public class MarketingAnalysisV3Controller { MarketingStrategy strategy = optStrategy.get(); if (!strategy.getUserId().equals(userId)) return forbiddenResponse(); - StrategyHistoryResponse response = convertToStrategyHistoryResponse(strategy); - return ResponseEntity.ok(ApiResponse.success(response)); + return ResponseEntity.ok(ApiResponse.success(convertToStrategyHistoryResponse(strategy))); } catch (Exception e) { return internalErrorResponse(e); } } + // ========================================================= + // STRATEGY — START / TASKS + // ========================================================= + @PostMapping("/strategy/{strategyId}/start") public ResponseEntity startStrategy( @RequestHeader(value = "Authorization", required = false) String authHeader, - @PathVariable String strategyId - ) { + @PathVariable String strategyId) { + String userId = extractUserIdFromHeader(authHeader); if (userId == null) return unauthorizedResponse(); @@ -406,23 +437,24 @@ public class MarketingAnalysisV3Controller { if (!strategy.getUserId().equals(userId)) return forbiddenResponse(); if (!"completed".equals(strategy.getStatus())) { - ErrorResponse error = new ErrorResponse("INVALID_STATUS", "Стратегия еще не завершена. Статус: " + strategy.getStatus()); - return ResponseEntity.status(400).body(ApiResponse.error("Стратегия не готова к запуску", error)); + return ResponseEntity.status(400).body(ApiResponse.error( + "Стратегия не готова к запуску", + new ErrorResponse("INVALID_STATUS", + "Стратегия ещё не завершена. Статус: " + strategy.getStatus()))); } - List tasks = postingTaskService.createTasksFromStrategy(strategyId); - List platforms = tasks.stream().map(PostingTask::getPlatform).distinct().collect(Collectors.toList()); + List tasks = postingTaskService.createTasksFromStrategy(strategyId); + List platforms = tasks.stream() + .map(PostingTask::getPlatform).distinct().collect(Collectors.toList()); - StartStrategyResponse response = new StartStrategyResponse( - strategyId, - tasks.size(), - platforms, - "Стратегия успешно запущена. Создано задач: " + tasks.size() - ); - - return ResponseEntity.ok(ApiResponse.success("Стратегия успешно запущена", response)); + return ResponseEntity.ok(ApiResponse.success( + "Стратегия успешно запущена", + new StartStrategyResponse(strategyId, tasks.size(), platforms, + "Стратегия запущена. Создано задач: " + tasks.size()))); } catch (IllegalStateException e) { - return ResponseEntity.status(400).body(ApiResponse.error("Не удалось запустить стратегию", new ErrorResponse("ERROR", e.getMessage()))); + return ResponseEntity.status(400).body(ApiResponse.error( + "Не удалось запустить стратегию", + new ErrorResponse("ERROR", e.getMessage()))); } catch (Exception e) { return internalErrorResponse(e); } @@ -431,8 +463,8 @@ public class MarketingAnalysisV3Controller { @PostMapping("/tasks/{taskId}/execute") public ResponseEntity executeTaskManually( @RequestHeader(value = "Authorization", required = false) String authHeader, - @PathVariable String taskId - ) { + @PathVariable String taskId) { + String userId = extractUserIdFromHeader(authHeader); if (userId == null) return unauthorizedResponse(); @@ -445,94 +477,95 @@ public class MarketingAnalysisV3Controller { postingTaskService.executeTaskManually(taskId); - Optional updatedTask = postingTaskService.getTaskById(taskId); - if (updatedTask.isPresent()) { - PostingTask taskData = updatedTask.get(); - Map responseData = Map.of( - "taskId", taskData.getId(), - "status", taskData.getStatus(), - "platform", taskData.getPlatform(), - "publishDate", taskData.getPublishDate() - ); - return ResponseEntity.ok(ApiResponse.success("Задача успешно запущена", responseData)); + Optional updated = postingTaskService.getTaskById(taskId); + if (updated.isPresent()) { + PostingTask t = updated.get(); + return ResponseEntity.ok(ApiResponse.success("Задача успешно запущена", + Map.of("taskId", t.getId(), "status", t.getStatus(), + "platform", t.getPlatform(), "publishDate", t.getPublishDate()))); } - return ResponseEntity.ok(ApiResponse.success("Задача отправлена в обработку", Map.of("taskId", taskId))); + return ResponseEntity.ok(ApiResponse.success("Задача отправлена в обработку", + Map.of("taskId", taskId))); } catch (IllegalStateException e) { - return ResponseEntity.status(400).body(ApiResponse.error("Задача не может быть запущена", new ErrorResponse("INVALID_STATUS", e.getMessage()))); + return ResponseEntity.status(400).body(ApiResponse.error( + "Задача не может быть запущена", + new ErrorResponse("INVALID_STATUS", e.getMessage()))); } catch (Exception e) { return internalErrorResponse(e); } } + // ========================================================= + // STRATEGY — REGENERATE MEDIA + // ========================================================= + @PostMapping("/strategy/{strategyId}/post/{postIndex}/regenerate-image") public ResponseEntity regeneratePostImage( @RequestHeader(value = "Authorization", required = false) String authHeader, @PathVariable String strategyId, - @PathVariable int postIndex - ) { + @PathVariable int postIndex) { + String userId = extractUserIdFromHeader(authHeader); if (userId == null) return unauthorizedResponse(); try { Optional optStrategy = strategyService.getStrategyById(strategyId); if (optStrategy.isEmpty()) return notFoundResponse("Стратегия не найдена"); + if (!optStrategy.get().getUserId().equals(userId)) return forbiddenResponse(); - MarketingStrategy strategy = optStrategy.get(); - if (!strategy.getUserId().equals(userId)) return forbiddenResponse(); - - MarketingStrategy.PostCalendarItem updatedItem = strategyService.regeneratePostImage(strategyId, postIndex); + MarketingStrategy.PostCalendarItem updatedItem = + strategyService.regeneratePostImage(strategyId, postIndex); if (updatedItem == null) return notFoundResponse("Пост не найден"); Map responseData = new HashMap<>(); - responseData.put("strategyId", strategyId); - responseData.put("postIndex", postIndex); - responseData.put("imageUrl", updatedItem.getImageUrl() != null ? updatedItem.getImageUrl() : ""); + responseData.put("strategyId", strategyId); + responseData.put("postIndex", postIndex); + responseData.put("imageUrl", updatedItem.getImageUrl() != null ? updatedItem.getImageUrl() : ""); responseData.put("imageFilename", updatedItem.getImageFilename() != null ? updatedItem.getImageFilename() : ""); - responseData.put("videoUrl", updatedItem.getVideoUrl() != null ? updatedItem.getVideoUrl() : ""); + responseData.put("videoUrl", updatedItem.getVideoUrl() != null ? updatedItem.getVideoUrl() : ""); responseData.put("videoFilename", updatedItem.getVideoFilename() != null ? updatedItem.getVideoFilename() : ""); - responseData.put("theme", updatedItem.getTheme() != null ? updatedItem.getTheme() : ""); - responseData.put("platform", updatedItem.getPlatform() != null ? updatedItem.getPlatform() : ""); - responseData.put("publishDate", updatedItem.getPublishDate()); + responseData.put("theme", updatedItem.getTheme() != null ? updatedItem.getTheme() : ""); + responseData.put("platform", updatedItem.getPlatform() != null ? updatedItem.getPlatform() : ""); + responseData.put("publishDate", updatedItem.getPublishDate()); - return ResponseEntity.ok(ApiResponse.success("Изображение для поста успешно регенерировано", responseData)); + return ResponseEntity.ok(ApiResponse.success("Изображение успешно регенерировано", responseData)); } catch (Exception e) { return internalErrorResponse(e); } } - // ИСПРАВЛЕННЫЙ ЭНДПОИНТ ДЛЯ АСИНХРОННОЙ РЕГЕНЕРАЦИИ ВИДЕО @PostMapping("/strategy/{strategyId}/post/{postIndex}/regenerate-video") public ResponseEntity regeneratePostVideo( @RequestHeader(value = "Authorization", required = false) String authHeader, @PathVariable String strategyId, - @PathVariable int postIndex - ) { + @PathVariable int postIndex) { + String userId = extractUserIdFromHeader(authHeader); if (userId == null) return unauthorizedResponse(); try { Optional optStrategy = strategyService.getStrategyById(strategyId); if (optStrategy.isEmpty()) return notFoundResponse("Стратегия не найдена"); + if (!optStrategy.get().getUserId().equals(userId)) return forbiddenResponse(); - MarketingStrategy strategy = optStrategy.get(); - if (!strategy.getUserId().equals(userId)) return forbiddenResponse(); - - // Вызываем АСИНХРОННЫЙ метод сервиса + // Асинхронный вызов — сразу отдаём 202 strategyService.regeneratePostVideoAsync(strategyId, postIndex); - // Мгновенно отдаем ответ фронту со статусом 202 Accepted - Map responseData = new HashMap<>(); - responseData.put("strategyId", strategyId); - responseData.put("postIndex", postIndex); - responseData.put("message", "Генерация видео запущена в фоновом режиме. Видео появится через несколько минут."); - - return ResponseEntity.accepted().body(ApiResponse.success("Генерация видео запущена", responseData)); + return ResponseEntity.accepted().body(ApiResponse.success( + "Генерация видео запущена", + Map.of("strategyId", strategyId, + "postIndex", postIndex, + "message", "Видео генерируется в фоновом режиме. Появится через несколько минут."))); } catch (Exception e) { return internalErrorResponse(e); } } + // ========================================================= + // HELPERS + // ========================================================= + private StrategyHistoryResponse convertToStrategyHistoryResponse(MarketingStrategy strategy) { StrategyHistoryResponse response = new StrategyHistoryResponse(); response.setStrategyId(strategy.getId()); @@ -548,32 +581,37 @@ public class MarketingAnalysisV3Controller { } @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> handleValidationException(MethodArgumentNotValidException ex) { + public ResponseEntity> handleValidationException( + MethodArgumentNotValidException ex) { Map details = new HashMap<>(); - ex.getBindingResult().getFieldErrors().forEach(error -> - details.put(error.getField(), error.getDefaultMessage()) - ); - ErrorResponse error = new ErrorResponse("VALIDATION_ERROR", "Ошибка валидации", details); - return ResponseEntity.badRequest().body(ApiResponse.error("Ошибка валидации", error)); + ex.getBindingResult().getFieldErrors() + .forEach(e -> details.put(e.getField(), e.getDefaultMessage())); + return ResponseEntity.badRequest().body(ApiResponse.error("Ошибка валидации", + new ErrorResponse("VALIDATION_ERROR", "Ошибка валидации", details))); } private ResponseEntity> unauthorizedResponse() { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED) - .body(ApiResponse.error("Не авторизован", new ErrorResponse("UNAUTHORIZED", "Требуется авторизация. Предоставьте валидный JWT токен."))); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ApiResponse.error( + "Не авторизован", + new ErrorResponse("UNAUTHORIZED", "Требуется авторизация. Предоставьте валидный JWT токен."))); } private ResponseEntity> forbiddenResponse() { - return ResponseEntity.status(HttpStatus.FORBIDDEN) - .body(ApiResponse.error("Доступ запрещен", new ErrorResponse("FORBIDDEN", "У вас нет прав для доступа к этому ресурсу."))); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.error( + "Доступ запрещён", + new ErrorResponse("FORBIDDEN", "У вас нет прав для доступа к этому ресурсу."))); } private ResponseEntity> notFoundResponse(String message) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(ApiResponse.error("Не найдено", new ErrorResponse("NOT_FOUND", message))); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponse.error( + "Не найдено", + new ErrorResponse("NOT_FOUND", message))); } private ResponseEntity> internalErrorResponse(Exception e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(ApiResponse.error("Ошибка сервера", new ErrorResponse("INTERNAL_SERVER_ERROR", e.getMessage()))); + log.error("Internal error: {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ApiResponse.error( + "Ошибка сервера", + new ErrorResponse("INTERNAL_SERVER_ERROR", e.getMessage()))); } } \ No newline at end of file diff --git a/src/main/java/kz/konturai/parser/dto/MarketingAnalysisV3Result.java b/src/main/java/kz/konturai/parser/dto/MarketingAnalysisV3Result.java index 76a7ba6..1270a64 100644 --- a/src/main/java/kz/konturai/parser/dto/MarketingAnalysisV3Result.java +++ b/src/main/java/kz/konturai/parser/dto/MarketingAnalysisV3Result.java @@ -5,6 +5,7 @@ import lombok.Data; import java.util.List; import java.util.Map; + @Data public class MarketingAnalysisV3Result { @@ -44,118 +45,120 @@ public class MarketingAnalysisV3Result { @JsonProperty("11_smm_strategy_rationale") private String smmStrategyRationale; + // ========================================================== + @Data public static class ExecutiveSummary { - private String businessStage; - private String geography; - private int activeCompetitors; - private String competitionLevel; - private double averageNicheEr; - private double averageRating; - private String demandTrend; - private List keyFigures; + private String businessStage = ""; + private String geography = ""; + private int activeCompetitors = 0; + private String competitionLevel = ""; + private double averageNicheEr = 0.0; + private double averageRating = 0.0; + private String demandTrend = ""; + private List keyFigures = List.of(); } @Data public static class MarketLandscape { - private Map activePlayersByPlatform; - private Map cityDistribution; - private double nicheReputationLevel; + private Map activePlayersByPlatform = Map.of(); + private Map cityDistribution = Map.of(); + private double nicheReputationLevel = 0.0; } @Data public static class GeoStructure { - private List cityComparison; - private double densityIndex; + private List cityComparison = List.of(); + private double densityIndex = 0.0; } @Data public static class CityMetrics { - private String city; - private int activePlayers; - private double avgEr; - private double avgRating; - private int avgPostsPerMonth; + private String city = ""; + private int activePlayers = 0; + private double avgEr = 0.0; + private double avgRating = 0.0; + private int avgPostsPerMonth = 0; } @Data public static class CompetitorProfile { - private String name; - private String platform; - private int followers; - private int postsPerMonth; - private double er; - private double rating; - private int reviews; - private List strengths; - private List weaknesses; + private String name = ""; + private String platform = ""; + private int followers = 0; + private int postsPerMonth = 0; + private double er = 0.0; + private double rating = 0.0; + private int reviews = 0; + private List strengths = List.of(); + private List weaknesses = List.of(); } @Data public static class ContentProfile { - private double demoContentPercent; - private double expertContentPercent; - private double salesContentPercent; - private double reviewsContentPercent; - private double engagementContentPercent; - private double videoShare; - private int avgTextLengthCharacters; - private double ctaUsagePercent; + private double demoContentPercent = 0.0; + private double expertContentPercent = 0.0; + private double salesContentPercent = 0.0; + private double reviewsContentPercent = 0.0; + private double engagementContentPercent = 0.0; + private double videoShare = 0.0; + private int avgTextLengthCharacters = 0; + private double ctaUsagePercent = 0.0; } @Data public static class CompetitionIntensity { - private int ciiIndex; - private String intensityLabel; - private List contributingFactors; + private int ciiIndex = 0; + private String intensityLabel = ""; + private List contributingFactors = List.of(); } @Data public static class ReputationAnalysis { - private double avgNicheRating; - private int medianReviews; - private Map starDistribution; - private double highTrustBusinessShare; - private int avgOwnerResponseTimeHours; + private double avgNicheRating = 0.0; + private int medianReviews = 0; + private Map starDistribution = Map.of(); + private double highTrustBusinessShare = 0.0; + private int avgOwnerResponseTimeHours = 0; } @Data public static class BehavioralPattern { - private double promoUsagePercent; - private double directBookingPercent; - private double priceVisibilityPercent; - private int avgCycleDays; - private List commonCta; + private double promoUsagePercent = 0.0; + private double directBookingPercent = 0.0; + private double priceVisibilityPercent = 0.0; + private int avgCycleDays = 0; + private List commonCta = List.of(); } @Data public static class SearchDemand { - private int avgMonthlySearches; - private List seasonality; - private List peakPeriods; - private List relatedQueries; + private int avgMonthlySearches = 0; + private List seasonality = List.of(); + private List peakPeriods = List.of(); + private List relatedQueries = List.of(); } @Data public static class UserPositioning { - private RadarMetrics marketMetrics; - private RadarMetrics userMetrics; - private String mode; - private List strategicFocus; + private RadarMetrics marketMetrics = new RadarMetrics(); + private RadarMetrics userMetrics = new RadarMetrics(); + private String mode = "BENCHMARK"; + private List strategicFocus = List.of(); } @Data public static class RadarMetrics { - private int activity; - private int engagement; - private int video; - private int reputation; - private int frequency; + private int activity = 0; + private int engagement = 0; + private int video = 0; + private int reputation = 0; + private int frequency = 0; } @Data public static class TimeSeriesPoint { - private String period; - private double value; + private String period = ""; + private double value = 0.0; } } \ No newline at end of file diff --git a/src/main/java/kz/konturai/parser/dto/ScoringResult.java b/src/main/java/kz/konturai/parser/dto/ScoringResult.java new file mode 100644 index 0000000..73acea7 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/ScoringResult.java @@ -0,0 +1,21 @@ +package kz.konturai.parser.dto; + +import kz.konturai.parser.enums.StrategyModel; +import kz.konturai.parser.enums.StrategyModifier; +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.util.List; + +@Data +@AllArgsConstructor +public class ScoringResult { + private StrategyModel model; + private int entryScore; + private int authorityScore; + private int trustScore; + private int conversionScore; + private List activeModifiers; + + private String rationale; +} \ No newline at end of file diff --git a/src/main/java/kz/konturai/parser/enums/StrategyModel.java b/src/main/java/kz/konturai/parser/enums/StrategyModel.java index a6f5d62..655e0ac 100644 --- a/src/main/java/kz/konturai/parser/enums/StrategyModel.java +++ b/src/main/java/kz/konturai/parser/enums/StrategyModel.java @@ -1,19 +1,143 @@ package kz.konturai.parser.enums; +import lombok.AllArgsConstructor; +import lombok.Getter; + + +@Getter +@AllArgsConstructor public enum StrategyModel { - ENTRY("Модель Входа (Entry)", "Много объясняющего (30%) и демо (25%). Темы: 'Как мы работаем', 'Что это за продукт'."), - AUTHORITY("Экспертная модель (Authority)", "Экспертный контент (40%) и кейсы (25%). Темы: 'Разбор кейса', 'Аналитика'."), - TRUST("Модель Доверия (Trust)", "Кейсы (30%) и отзывы (25%). Темы: 'Отзывы клиентов', 'Процесс изнутри'."), - CONVERSION("Конверсионная модель (Conversion)", "Продающий (35%) и демо (30%). Темы: 'Акция', 'Ограниченное предложение'."); + + ENTRY( + "Entry — Вход / Формирование присутствия", + """ + === МОДЕЛЬ: ENTRY (Вход / Формирование присутствия) === + + КОГДА ВЫБИРАЕТСЯ: launch, слабое знание бренда, нет доверия у аудитории. + СТРАТЕГИЧЕСКАЯ ЦЕЛЬ: Сформировать узнаваемость и базовое доверие. Закрепить присутствие на рынке. + ГЛАВНЫЙ БАРЬЕР: О бизнесе не знают или не понимают, чем он отличается. + РОЛЬ SMM: Создание фундамента доверия и первичного интереса. + + ОБЯЗАТЕЛЬНАЯ КОНТЕНТНАЯ АРХИТЕКТУРА (строго соблюдать пропорции): + - Объясняющий: 30% постов — объяснить что делаем, чем отличаемся + - Демонстрационный: 25% постов — показать продукт/процесс/результат вживую + - Социальное доказательство: 20% постов — первые отзывы, реакции, клиенты + - Экспертный: 15% постов — показать компетенцию без давления + - Продающий: 10% постов — мягкая конверсия, никакой агрессии + + ФОРМАТНАЯ МОДЕЛЬ: + - Видео (contentType: "видео"): 40% — объясняющие видео, демонстрации, знакомство с командой + - Фото/карусель (contentType: "фото"): 40% — продукт, процесс, люди + - Текстовые (contentType: "фото"): 20% — истории, объяснения + + CTA СТИЛЬ — только мягкий, без давления: + Использовать: «Узнать подробнее», «Написать в директ», «Задать вопрос», «Познакомиться с нами» + НЕ использовать: «Купить сейчас», «Заказать», «Акция», «Ограничено» + + КОММУНИКАЦИОННЫЙ СТИЛЬ: Понятный, открытый, без агрессии и давления на покупку. + ЧАСТОТА: 3-4 поста в неделю. Важно накопить присутствие быстро. + КРИТЕРИИ ЭФФЕКТИВНОСТИ: Рост охвата, рост подписчиков, первые обращения, вовлечённость. + """ + ), + + AUTHORITY( + "Authority — Экспертность / Компетентность", + """ + === МОДЕЛЬ: AUTHORITY (Экспертность / Компетентность) === + + КОГДА ВЫБИРАЕТСЯ: длинный цикл сделки, высокий чек, B2B-сегмент. + СТРАТЕГИЧЕСКАЯ ЦЕЛЬ: Убедить что бизнес — профессионал и эксперт в своей нише. + ГЛАВНЫЙ БАРЬЕР: Сомнение в компетенции, клиент долго принимает решение. + РОЛЬ SMM: Подкрепление экспертного статуса через глубину контента и аргументацию. + + ОБЯЗАТЕЛЬНАЯ КОНТЕНТНАЯ АРХИТЕКТУРА (строго соблюдать пропорции): + - Экспертный: 40% постов — разборы, аналитика, мнения, глубокие объяснения + - Кейсы: 25% постов — реальные результаты с цифрами, до/после + - Объясняющий: 15% постов — упрощение сложного, образовательный контент + - Социальное доказательство: 10% постов — отзывы клиентов, партнёры, сертификаты + - Продающий: 10% постов — мягкий переход к заявке + + ФОРМАТНАЯ МОДЕЛЬ: + - Видео с объяснениями (contentType: "видео"): 35% — разборы, мини-лекции, кейсы + - Карусели/разборы (contentType: "фото"): 35% — пошаговые инструкции, сравнения + - Текст+аналитика (contentType: "фото"): 30% — экспертные посты с глубиной + + CTA СТИЛЬ — рациональный, деловой: + Использовать: «Получить расчёт», «Записаться на консультацию», «Обсудить проект», «Узнать детали» + НЕ использовать: «Купить прямо сейчас», «Скидка только сегодня» + + КОММУНИКАЦИОННЫЙ СТИЛЬ: Рациональный, уверенный, аргументированный. Глубина важнее объёма. + ЧАСТОТА: 3 поста в неделю стабильно. Глубина важнее частоты. + КРИТЕРИИ ЭФФЕКТИВНОСТИ: Качество лидов, глубина взаимодействия, время просмотра, число консультаций. + """ + ), + + TRUST( + "Trust — Усиление доверия / Перегретый рынок", + """ + === МОДЕЛЬ: TRUST (Усиление доверия / Перегретый рынок) === + + КОГДА ВЫБИРАЕТСЯ: высокая конкуренция, клиенты требуют кейсы и отзывы, рынок одинаковых предложений. + СТРАТЕГИЧЕСКАЯ ЦЕЛЬ: Снять недоверие и показать реальное отличие от конкурентов. + ГЛАВНЫЙ БАРЬЕР: Рынок перенасыщен, все выглядят одинаково, клиент не верит. + РОЛЬ SMM: Подтвердить надёжность и реальность результатов через доказательства. + + ОБЯЗАТЕЛЬНАЯ КОНТЕНТНАЯ АРХИТЕКТУРА (строго соблюдать пропорции): + - Кейсы: 30% постов — конкретные результаты с цифрами, имена клиентов + - Отзывы клиентов: 25% постов — реальные люди, видеоотзывы, скриншоты + - Демонстрация процесса: 20% постов — прозрачность: «вот как мы работаем» + - Экспертный: 15% постов — подкрепление доверия знаниями + - Продающий: 10% постов — аккуратная конверсия без давления + + ФОРМАТНАЯ МОДЕЛЬ: + - Реальные видео (contentType: "видео"): 50% — ПРИОРИТЕТ. Видео доверие = живые люди + - Фото до/после, скриншоты (contentType: "фото"): 30% — доказательства результата + - Текст с историями (contentType: "фото"): 20% — истории клиентов, детали кейсов + + CTA СТИЛЬ — умеренный, доказательный: + Использовать: «Посмотрите результат», «Оцените кейс», «Запросить пример работы», «Узнать детали» + НЕ использовать: «Купить сейчас», «Только сегодня», давление на срочность + + КОММУНИКАЦИОННЫЙ СТИЛЬ: Честный, прозрачный, спокойный, без пафоса и хайпа. + ЧАСТОТА: 3 поста в неделю стабильно. Регулярность = доверие. + КРИТЕРИИ ЭФФЕКТИВНОСТИ: Рост доверительных обращений, сохранения, глубина просмотра. + """ + ), + + CONVERSION( + "Conversion — Прямая конверсия / Импульс", + """ + === МОДЕЛЬ: CONVERSION (Прямая конверсия / Импульс) === + + КОГДА ВЫБИРАЕТСЯ: быстрые покупки, низкий/средний чек, визуальный продукт, B2C. + СТРАТЕГИЧЕСКАЯ ЦЕЛЬ: Максимизировать заявки и продажи прямо сейчас. + ГЛАВНЫЙ БАРЬЕР: Нет достаточного импульса к действию, клиент откладывает. + РОЛЬ SMM: Создание регулярного стимула к покупке через яркий контент и прямые офферы. + + ОБЯЗАТЕЛЬНАЯ КОНТЕНТНАЯ АРХИТЕКТУРА (строго соблюдать пропорции): + - Продающий: 35% постов — чёткий оффер, выгода, ограниченность, акции + - Демонстрационный: 30% постов — продукт в действии, до/после, процесс + - Вовлекающий: 20% постов — конкурсы, вопросы, интерактив, удержание + - Социальное доказательство: 10% постов — снять последнее сомнение перед покупкой + - Объясняющий: 5% постов — минимально, только если продукт новый + + ФОРМАТНАЯ МОДЕЛЬ: + - Короткие видео/Reels (contentType: "видео"): 60% — ПРИОРИТЕТ. Быстро, ярко, с CTA + - Яркое фото продукта (contentType: "фото"): 30% — продукт крупно, красиво, аппетитно + - Текст-оффер (contentType: "фото"): 10% — акция, условия, призыв + + CTA СТИЛЬ — прямой, без лишних слов: + Использовать: «Купить», «Заказать», «Забронировать», «Написать сейчас», «Успей до [дата]» + Добавлять срочность и ограниченность: «Осталось 3 места», «Только до пятницы» + + КОММУНИКАЦИОННЫЙ СТИЛЬ: Прямой, конкретный, энергичный. Без лишних объяснений. + ЧАСТОТА: 4-5 постов в неделю. Частота = больше касаний = больше заявок. + КРИТЕРИИ ЭФФЕКТИВНОСТИ: Количество заявок, CTR, конверсия, стоимость лида. + """ + ); private final String title; + + /** Полные правила контентной архитектуры — передаются в AI промпт */ 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/enums/StrategyModifier.java b/src/main/java/kz/konturai/parser/enums/StrategyModifier.java new file mode 100644 index 0000000..9a88bf3 --- /dev/null +++ b/src/main/java/kz/konturai/parser/enums/StrategyModifier.java @@ -0,0 +1,92 @@ +package kz.konturai.parser.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * Модификаторы базовой стратегической модели. + * НЕ заменяют модель — корректируют % контента, частоту, тон, CTA. + * Максимум 3 активных модификатора одновременно (согласно документу). + */ +@Getter +@AllArgsConstructor +public enum StrategyModifier { + + HIGH_COMPETITION( + "Высокая конкуренция", + """ + МОДИФИКАТОР HIGH_COMPETITION активен — рынок насыщен, выделиться сложно. + Корректировки к базовой модели: + - +10% к кейсам (доказать реальность результатов) + - +10% к социальному доказательству (отзывы, скриншоты, живые клиенты) + - -10% к продающему контенту (сначала доверие, потом продажа) + - В каждом посте — уникальный угол позиционирования, не быть как все + """ + ), + + HIGH_CHECK( + "Высокий средний чек", + """ + МОДИФИКАТОР HIGH_CHECK активен — клиент долго думает перед дорогой покупкой. + Корректировки к базовой модели: + - +10% к экспертному контенту (обосновать ценность и цену) + - +10% к объясняющему контенту (удлинить прогрев, снять барьер высокой цены) + - -10% к агрессивным CTA (заменить «Купить» на «Узнать подробнее», «Обсудить») + - Акцент на ценность и результат, а не на саму цену + """ + ), + + SHORT_DECISION( + "Быстрый цикл принятия решения", + """ + МОДИФИКАТОР SHORT_DECISION активен — клиенты покупают быстро, импульсивно. + Корректировки к базовой модели: + - +10-15% к продающему контенту (больше прямых офферов с конкретной выгодой) + - Сильный CTA в каждом посте — конкретный призыв к действию + - Повышение частоты публикаций (+1 пост в неделю) + - Добавить элементы срочности и ограниченности в каждый продающий пост + """ + ), + + SOCIAL_PROOF_SENSITIVE( + "Клиенты ориентированы на отзывы и кейсы", + """ + МОДИФИКАТОР SOCIAL_PROOF_SENSITIVE активен — аудитория требует доказательств перед покупкой. + Корректировки к базовой модели: + - +15% к отзывам и кейсам (реальные истории с деталями и цифрами) + - Создать регулярную рубрику результатов (минимум 1 раз в неделю) + - Усилить прозрачность: показывать процесс, не только итог + - Использовать скриншоты переписок, реакций, благодарностей клиентов + """ + ), + + RETENTION( + "Повторные продажи / подписка", + """ + МОДИФИКАТОР RETENTION активен — продукт предполагает регулярные покупки или подписку. + Корректировки к базовой модели: + - +10% к полезному/образовательному контенту (давать ценность постоянно) + - Добавить регулярные рубрики (напр. «Совет недели», «Кейс месяца», «Лайфхак») + - Напоминания о продукте без агрессии — польза, а не давление + - Акцент на системность и постоянство, контент «удерживает» подписчика + """ + ), + + MULTI_GEO( + "Несколько городов", + """ + МОДИФИКАТОР MULTI_GEO активен — бизнес работает в нескольких городах. + Корректировки к базовой модели: + - Локализация: упоминать конкретный город в теме и тексте поста + - Чередовать посты с акцентом на разные города (не смешивать в одном посте) + - Адаптировать офферы под локальные особенности каждого рынка + - Добавлять геотеги каждого города в хэштеги (#алматы #астана и т.д.) + - Показывать работу в конкретных локациях + """ + ); + + private final String title; + + /** Инструкции для AI — как применить модификатор к контент-плану */ + private final String instructions; +} \ No newline at end of file diff --git a/src/main/java/kz/konturai/parser/service/MarketingAnalysisV3Service.java b/src/main/java/kz/konturai/parser/service/MarketingAnalysisV3Service.java index 7f27767..4f54669 100644 --- a/src/main/java/kz/konturai/parser/service/MarketingAnalysisV3Service.java +++ b/src/main/java/kz/konturai/parser/service/MarketingAnalysisV3Service.java @@ -1,9 +1,9 @@ package kz.konturai.parser.service; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import kz.konturai.parser.dto.*; +import kz.konturai.parser.enums.*; import kz.konturai.parser.model.MarketingAnalysisV3Document; import kz.konturai.parser.repository.MarketingAnalysisV3Repository; import lombok.RequiredArgsConstructor; @@ -14,7 +14,7 @@ import org.springframework.stereotype.Service; import java.time.LocalDateTime; import java.util.*; -import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -29,159 +29,604 @@ public class MarketingAnalysisV3Service { @Value("${openai.model.name.text:gpt-4o}") private String highIntelligenceModel; + // ===================================================================== + // PUBLIC API + // ===================================================================== + public String createAndStartAnalysis(MarketingAnalysisV3Request request, String userId) { log.info("Creating new Analysis V3 for user: {}", userId); - MarketingAnalysisV3Document doc = new MarketingAnalysisV3Document(); doc.setUserId(userId); doc.setRequestData(request); doc.setStatus("QUEUED"); doc.setCreatedAt(LocalDateTime.now()); doc.setUpdatedAt(LocalDateTime.now()); - doc = repository.save(doc); log.info("Analysis document saved with ID: {}", doc.getId()); - processAnalysisAsync(doc.getId(), request); return doc.getId(); } + public Optional getAnalysisById(String id) { + return repository.findById(id); + } + + public List getAllByUser(String userId) { + return repository.findAllByUserIdOrderByCreatedAtDesc(userId); + } + + // ===================================================================== + // ASYNC PIPELINE + // ===================================================================== + @Async("reportGenerationExecutor") public void processAnalysisAsync(String docId, MarketingAnalysisV3Request request) { try { - log.info("[Analysis ID: {}] Started async processing", docId); + log.info("[{}] Started async processing", docId); updateStatus(docId, "PROCESSING"); - log.info("[Analysis ID: {}] Step 1/3: Executing Deep Research", docId); - Map researchPack = executeDeepResearch(request); - saveResearchMetaData(docId, researchPack); + // Step 1: Search + log.info("[{}] Step 1/4: Deep Research", docId); + Map searchResults = executeDeepResearch(request); + saveResearchSnapshot(docId, searchResults); - // Защита: Если поиск упал, но мы продолжаем, чтобы выдать хоть что-то - if (!hasValidData(researchPack)) { - log.warn("[Analysis ID: {}] SEARCH FAILED. Forcing AI fallback protocol.", docId); - } + // Step 2: Digest — из SerperSearchResult извлекаем читаемый текст для AI + log.info("[{}] Step 2/4: Digesting evidence", docId); + String evidence = buildEvidence(searchResults, request); + log.info("[{}] Evidence: {} chars, {} search blocks", docId, evidence.length(), searchResults.size()); - log.info("[Analysis ID: {}] Step 2/3: Building Prompts", docId); - String systemPrompt = buildKazakhstanSystemPrompt(); - String userPrompt = buildDataDrivenUserPrompt(request, researchPack); + // Step 3: Prompts + log.info("[{}] Step 3/4: Building prompts", docId); + String systemPrompt = buildSystemPrompt(request); + String userPrompt = buildUserPrompt(request, evidence); - log.info("[Analysis ID: {}] Step 3/3: Calling AI Model ({})", docId, highIntelligenceModel); - String jsonResponse = generateAiResponseWithRetry(docId, userPrompt, systemPrompt); + // Step 4: AI generation + log.info("[{}] Step 4/4: Calling AI model ({})", docId, highIntelligenceModel); + String jsonResponse = generateWithRetry(docId, systemPrompt, userPrompt); - log.info("[Analysis ID: {}] Parsing and validating AI result", docId); - MarketingAnalysisV3Result result = parseAndValidateResult(jsonResponse); + log.info("[{}] Parsing result", docId); + MarketingAnalysisV3Result result = parseAndFix(jsonResponse); completeAnalysis(docId, result); - log.info("[Analysis ID: {}] Analysis successfully completed!", docId); + log.info("[{}] Analysis completed successfully", docId); } catch (Exception e) { - log.error("[Analysis ID: {}] FAILED with error: {}", docId, e.getMessage(), e); + log.error("[{}] FAILED: {}", docId, e.getMessage(), e); failAnalysis(docId, e.getMessage()); } } - private boolean hasValidData(Map researchPack) { - if (researchPack == null || researchPack.isEmpty()) return false; - for (Map.Entry entry : researchPack.entrySet()) { - if (entry.getKey().equals("generatedAt")) continue; - Object value = entry.getValue(); - if (value instanceof Map) { - Map mapValue = (Map) value; - if ("OK".equals(mapValue.get("status"))) { - Object items = mapValue.get("items"); - if (items instanceof List && !((List) items).isEmpty()) return true; - } - } - } - return false; - } + // ===================================================================== + // STEP 1 — SEARCH + // ===================================================================== - private String sanitizeInput(String input) { - if (input == null || input.isBlank()) return ""; - return input.trim().replaceAll("\\s+", " ").replaceAll("(?i)залл\\b", "зал").replaceAll("(?i)салонн\\b", "салон").replaceAll("(?i)бизнес\\b", "бизнес"); - } + private Map executeDeepResearch(MarketingAnalysisV3Request request) { + String niche = sanitize(request.getBusinessNiche()); + String product = sanitize(request.getProductName()); + String topic = (niche + " " + product).trim(); + if (topic.isBlank()) topic = sanitize(request.getProductDescription()); - private Map executeDeepResearch(MarketingAnalysisV3Request request) { - Map pack = new ConcurrentHashMap<>(); - List queries = new ArrayList<>(); + List cities = getTargetCities(request); + String geo = String.join(" ", cities); - String niche = sanitizeInput(request.getBusinessNiche()); - String product = sanitizeInput(request.getProductName()); + // Простые конкретные запросы — Serper работает надёжнее без OR-site-нагромождений + Map queries = new LinkedHashMap<>(); + queries.put("market_overview", topic + " рынок Казахстан 2024 2025 статистика объём"); + queries.put("top_players", topic + " " + geo + " компании бренды Instagram лучшие"); + queries.put("google_maps", topic + " " + geo + " рейтинг отзывы 2GIS Google Maps"); + queries.put("prices", topic + " цены прайс " + geo + " 2024 2025"); + queries.put("pain_points", topic + " отзывы проблемы клиенты жалобы " + geo); + queries.put("smm_cases", niche + " SMM Instagram продвижение кейс Казахстан"); + queries.put("search_demand", topic + " частота запросов Казахстан сезонность"); - List cities = request.getPromotionCities() != null && !request.getPromotionCities().isEmpty() - ? request.getPromotionCities() - : (request.getPresenceCities() != null && !request.getPresenceCities().isEmpty() - ? request.getPresenceCities() - : List.of("Казахстан")); + Map results = Collections.synchronizedMap(new LinkedHashMap<>()); - String geoContext = String.join(" ", cities); - String topic = (niche + " " + product).trim(); - if (topic.isEmpty()) topic = sanitizeInput(request.getProductDescription()); - - String kzSites = "(site:stat.gov.kz OR site:kapital.kz OR site:kursiv.media OR site:forbes.kz OR site:ranking.kz)"; - String retailSites = "(site:2gis.kz OR site:kaspi.kz OR site:kolesa.kz OR site:krisha.kz OR site:chocofood.kz OR site:instagram.com)"; - - queries.add(String.format("%s %s статистика объем рынка Казахстан 2024 2025 %s", topic, geoContext, kzSites)); - queries.add(String.format("лучшие компании %s %s рейтинг отзывы %s", topic, geoContext, retailSites)); - queries.add(String.format("%s цены прайс %s 2024 2025", topic, geoContext)); - queries.add(String.format("жалобы отзывы проблемы клиентов %s %s форум", topic, geoContext)); - queries.add(String.format("кейс продвижение SMM %s казахстан", niche)); - - queries.parallelStream().forEach(q -> { - String safeMongoKey = q.replace(".", "_").replace("$", ""); + queries.entrySet().parallelStream().forEach(entry -> { + String key = entry.getKey(); + String query = entry.getValue(); try { - pack.put(safeMongoKey, searchService.search(q)); + SerperSearchResult res = searchService.search(query, 10, "kz", "ru"); + log.info("[Search] '{}' → status={}, items={}", + key, res.status(), res.items() == null ? 0 : res.items().size()); + results.put(key, res); } catch (Exception e) { - pack.put(safeMongoKey, Map.of("error", e.getMessage(), "status", "ERROR")); + log.warn("[Search] '{}' failed: {}", key, e.getMessage()); + results.put(key, new SerperSearchResult(query, "ERROR", e.getMessage(), List.of())); } }); - pack.put("generatedAt", LocalDateTime.now().toString()); - return pack; + return results; } - private String generateAiResponseWithRetry(String docId, String userPrompt, String systemPrompt) { - int attempts = 0; + // ===================================================================== + // STEP 2 — DIGEST + // SerperSearchItem record: (title, link, snippet, sourceHost) + // SerperSearchResult record: (query, status, error, items) + // ===================================================================== + + private String buildEvidence(Map searchResults, + MarketingAnalysisV3Request request) { + StringBuilder sb = new StringBuilder(); + sb.append("=== ДАННЫЕ ИЗ ПОИСКА (ОСНОВНОЙ ИСТОЧНИК ФАКТОВ) ===\n\n"); + sb.append("Ниша: ").append(sanitize(request.getBusinessNiche())).append("\n"); + sb.append("Продукт/услуга: ").append(sanitize(request.getProductName())).append("\n"); + sb.append("Целевые города: ").append(String.join(", ", getTargetCities(request))).append("\n\n"); + + int totalSnippets = 0; + + for (Map.Entry entry : searchResults.entrySet()) { + String key = entry.getKey(); + SerperSearchResult res = entry.getValue(); + + sb.append("--- ").append(sectionLabel(key)).append(" ---\n"); + + if (!"OK".equals(res.status()) || res.items() == null || res.items().isEmpty()) { + sb.append("(данные недоступны для этого блока)\n\n"); + continue; + } + + List items = res.items(); + int limit = Math.min(items.size(), 7); + for (int i = 0; i < limit; i++) { + SerperSearchItem item = items.get(i); + // record: (title, link, snippet, sourceHost) + String title = item.title(); + String link = item.link(); + String snippet = item.snippet(); + + if ((title == null || title.isBlank()) && (snippet == null || snippet.isBlank())) continue; + + sb.append(" [").append(i + 1).append("] "); + if (title != null && !title.isBlank()) sb.append(title).append("\n "); + if (snippet != null && !snippet.isBlank()) sb.append(snippet).append("\n"); + if (link != null && !link.isBlank()) sb.append(" Источник: ").append(link).append("\n"); + totalSnippets++; + } + sb.append("\n"); + } + + sb.append("=== ИТОГО ФАКТОВ ИЗ ПОИСКА: ").append(totalSnippets).append(" ===\n"); + if (totalSnippets == 0) { + sb.append("ВНИМАНИЕ: Поиск не вернул данных. " + + "Генерируй оценки по методу Ферми на основе знаний о рынке Казахстана.\n"); + } + + return sb.toString(); + } + + private String sectionLabel(String key) { + return switch (key) { + case "market_overview" -> "ОБЗОР РЫНКА"; + case "top_players" -> "ТОПОВЫЕ ИГРОКИ / КОНКУРЕНТЫ"; + case "google_maps" -> "РЕЙТИНГИ И ОТЗЫВЫ (Google Maps / 2GIS)"; + case "prices" -> "ЦЕНЫ"; + case "pain_points" -> "БОЛИ КЛИЕНТОВ / ОТЗЫВЫ"; + case "smm_cases" -> "SMM КЕЙСЫ"; + case "search_demand" -> "ПОИСКОВЫЙ СПРОС"; + default -> key.toUpperCase(); + }; + } + + // ===================================================================== + // STEP 3 — PROMPTS + // Все enum-значения читаем через .getDescription() для читаемого контекста + // ===================================================================== + + private String buildSystemPrompt(MarketingAnalysisV3Request request) { + String niche = sanitize(request.getBusinessNiche()); + String cities = String.join(", ", getTargetCities(request)); + + return """ + Ты — старший маркетинговый аналитик рынка Казахстана, специализация: "%s". + + ТВОЯ ЦЕЛЬ: Сгенерировать строго валидный JSON-отчёт маркетингового анализа на основе предоставленных данных. + + ИСТОЧНИКИ (приоритет по убыванию): + 1. Конкретные факты и названия из блока "ДАННЫЕ ИЗ ПОИСКА" в запросе пользователя + 2. Твои знания о рынке Казахстана в нише "%s", городах: %s + 3. Метод Ферми — только для полей, по которым нет других данных + + ОГРАНИЧИТЕЛИ РЕАЛИЗМА (НАРУШАТЬ ЗАПРЕЩЕНО — это физические ограничения): + - ER (Engagement Rate) в Instagram у бизнеса в KZ: 1.0%% – 12.0%%. Выше 15%% — невозможно. + - Рейтинг (rating): шкала от 1.0 до 5.0 включительно. Типичный бизнес: 4.0–4.8. + - Подписчики малого бизнеса KZ: 500–30,000. Среднего: 10,000–200,000. + - Постов в месяц: 4–25. + - avgMonthlySearches: узкая ниша 500–5,000/мес, популярная 10,000–80,000/мес. + - ciiIndex: строго от 0 до 100 (целое число). + - densityIndex: строго от 0.0 до 100.0. + - starDistribution: сумма 5 значений должна быть ровно 100.0. + - Radar-метрики (activity, engagement, video, reputation, frequency): строго от 0 до 100 (целые числа). + + ПРАВИЛА КАЧЕСТВА: + 1. В 3_competitor_map — только реальные бренды из данных поиска. Если поиск не дал имён — придумай правдоподобные казахстанские названия (НЕ "Компания А", НЕ "Бизнес 1"). + 2. Каждый город из запроса ОБЯЗАН присутствовать в cityDistribution и cityComparison. + 3. NULL в любом поле — ЗАПРЕЩЁН. Пустой список [] или 0 — допустимо. + 4. Все перечисляемые текстовые поля (businessStage, demandTrend, competitionLevel, intensityLabel) — строго на русском языке. + 5. keyFigures, contributingFactors, commonCta, relatedQueries, strategicFocus — конкретные, с цифрами где возможно. + 6. Формат вывода: только JSON объект. Никакого markdown, никаких пояснений вне JSON. + """.formatted(niche, niche, cities); + } + + private String buildUserPrompt(MarketingAnalysisV3Request request, String evidence) { + try { + List cities = getTargetCities(request); + + // --- Читаем все enum-поля через .getDescription() --- + String businessStageDesc = descOrEmpty(request.getBusinessStage()); + String clientTargetDesc = descOrEmpty(request.getClientTarget()); + String offerTypeDesc = descOrEmpty(request.getOfferType()); + String avgCheckDesc = descOrEmpty(request.getAverageCheck()); + String geoScopeDesc = descOrEmpty(request.getGeoScope()); + String purchaseFreqDesc = descOrEmpty(request.getPurchaseFrequency()); + String visualFactorDesc = descOrEmpty(request.getVisualFactor()); + String priceFeedbackDesc = descOrEmpty(request.getPriceFeedback()); + String smmStatusDesc = descOrEmpty(request.getSmmStatus()); + String leadVolumeDesc = descOrEmpty(request.getLeadVolume()); + String responseHandlerDesc = descOrEmpty(request.getResponseHandler()); + + String customerBehaviorsDesc = listDesc(request.getCustomerBehaviors()); + String decisionPrioritiesDesc = listDesc(request.getDecisionPriorities()); + String discoveryMethodsDesc = listDesc(request.getDiscoveryMethods()); + String constraintsDesc = listDesc(request.getConstraints()); + + // --- Позиционирование: зависит от smmStatus --- + // SmmStatus.NONE означает "нет соцсетей" → BENCHMARK режим + boolean hasSmmPresence = request.getSmmStatus() != null + && request.getSmmStatus() != SmmStatus.NONE; + + String positioningNote = !hasSmmPresence + ? "smmStatus=\"" + smmStatusDesc + "\" (нет активных соцсетей) → " + + "mode=\"BENCHMARK\", ВСЕ поля userMetrics = 0, marketMetrics = реальные рыночные данные" + : "smmStatus=\"" + smmStatusDesc + "\" (есть присутствие в соцсетях) → " + + "mode=\"COMPARISON\", userMetrics = оценка активности клиента на основе его статуса, " + + "marketMetrics = реальные рыночные данные"; + + // --- Шаблоны городов — AI не сможет "забыть" ни один город --- + String cityDistJson = cities.stream() + .map(c -> "\"" + c + "\": 0") + .collect(Collectors.joining(", ", "{", "}")); + + String cityCompJson = cities.stream() + .map(c -> "{\"city\": \"" + c + "\", \"activePlayers\": 0, " + + "\"avgEr\": 0.0, \"avgRating\": 0.0, \"avgPostsPerMonth\": 0}") + .collect(Collectors.joining(",\n ", "[\n ", "\n ]")); + + return """ + ЗАДАНИЕ: Сгенерируй маркетинговый анализ v4.0 для рынка Казахстана. + + === ПРОФИЛЬ КЛИЕНТА === + Ниша: %s + Продукт / бренд: %s + Цель: %s + Описание продукта: %s + + Этап бизнеса: %s + Целевая аудитория: %s + Тип предложения: %s + Средний чек: %s + Поведение клиентов: %s + + Охват: %s + Основной город: %s + Города присутствия: %s + Города продвижения: %s + + Частота покупки: %s + Визуальный фактор: %s + Приоритеты при выборе: %s + Как находят продукт: %s + Ценовая реакция: %s + + SMM статус: %s + Объём лидов: %s + Обработка обращений: %s + Ограничения бизнеса: %s + + Ссылки на соцсети клиента: %s + Известные конкуренты: %s + + %s + + === ТРЕБОВАНИЯ К КОНКРЕТНЫМ БЛОКАМ === + + [3_competitor_map] + - Минимум 5 конкурентов, максимум 10. + - Используй РЕАЛЬНЫЕ названия из блока "ТОПОВЫЕ ИГРОКИ" выше. + - Если известные конкуренты указаны клиентом — включи их обязательно. + - strengths и weaknesses: минимум 2 конкретных пункта каждый. + + [1_market_landscape → cityDistribution] — ИСПОЛЬЗУЙ ЭТОТ ШАБЛОН, замени 0 на реальные числа: + %s + + [2_geo_structure → cityComparison] — ИСПОЛЬЗУЙ ЭТОТ ШАБЛОН, замени 0 на реальные числа: + %s + + [9_user_positioning] — %s + + [10_structured_conclusions] — минимум 5 фактов с цифрами, НЕ советы. + Пример: "Средний ER в нише составляет X%%, что на Y%% выше среднего по KZ" + + [11_smm_strategy_rationale] — минимум 80 слов. + Учти профиль клиента: тип предложения "%s", аудитория "%s", визуальный фактор "%s". + Объясни почему именно SMM подходит для этого бизнеса, опираясь на цифры анализа. + + === JSON СТРУКТУРА (ЗАПОЛНИ ВСЕ ПОЛЯ РЕАЛЬНЫМИ ДАННЫМИ) === + { + "0_executive_summary": { + "businessStage": "строка на русском", + "geography": "строка на русском", + "activeCompetitors": 0, + "competitionLevel": "низкий | средний | высокий | очень высокий", + "averageNicheEr": 0.0, + "averageRating": 0.0, + "demandTrend": "растущий | стабильный | падающий", + "keyFigures": ["факт с цифрой", "факт с цифрой", "факт", "факт", "факт"] + }, + "1_market_landscape": { + "activePlayersByPlatform": {"Instagram": 0, "TikTok": 0, "GoogleMaps": 0}, + "cityDistribution": %s, + "nicheReputationLevel": 0.0 + }, + "2_geo_structure": { + "cityComparison": %s, + "densityIndex": 0.0 + }, + "3_competitor_map": [ + { + "name": "Реальное название бренда", + "platform": "Instagram", + "followers": 0, + "postsPerMonth": 0, + "er": 0.0, + "rating": 0.0, + "reviews": 0, + "strengths": ["сильная сторона 1", "сильная сторона 2"], + "weaknesses": ["слабая сторона 1", "слабая сторона 2"] + } + ], + "4_content_profile": { + "demoContentPercent": 0.0, + "expertContentPercent": 0.0, + "salesContentPercent": 0.0, + "reviewsContentPercent": 0.0, + "engagementContentPercent": 0.0, + "videoShare": 0.0, + "avgTextLengthCharacters": 0, + "ctaUsagePercent": 0.0 + }, + "5_competition_intensity": { + "ciiIndex": 0, + "intensityLabel": "строка на русском", + "contributingFactors": ["фактор 1", "фактор 2", "фактор 3"] + }, + "6_reputation_analysis": { + "avgNicheRating": 0.0, + "medianReviews": 0, + "starDistribution": {"5": 0.0, "4": 0.0, "3": 0.0, "2": 0.0, "1": 0.0}, + "highTrustBusinessShare": 0.0, + "avgOwnerResponseTimeHours": 0 + }, + "7_behavioral_pattern": { + "promoUsagePercent": 0.0, + "directBookingPercent": 0.0, + "priceVisibilityPercent": 0.0, + "avgCycleDays": 0, + "commonCta": ["CTA формулировка 1", "CTA формулировка 2", "CTA формулировка 3"] + }, + "8_search_demand": { + "avgMonthlySearches": 0, + "seasonality": [ + {"period": "Январь", "value": 0.0}, {"period": "Февраль", "value": 0.0}, + {"period": "Март", "value": 0.0}, {"period": "Апрель", "value": 0.0}, + {"period": "Май", "value": 0.0}, {"period": "Июнь", "value": 0.0}, + {"period": "Июль", "value": 0.0}, {"period": "Август", "value": 0.0}, + {"period": "Сентябрь", "value": 0.0}, {"period": "Октябрь", "value": 0.0}, + {"period": "Ноябрь", "value": 0.0}, {"period": "Декабрь", "value": 0.0} + ], + "peakPeriods": ["период 1", "период 2"], + "relatedQueries": ["запрос 1", "запрос 2", "запрос 3"] + }, + "9_user_positioning": { + "marketMetrics": {"activity": 0, "engagement": 0, "video": 0, "reputation": 0, "frequency": 0}, + "userMetrics": {"activity": 0, "engagement": 0, "video": 0, "reputation": 0, "frequency": 0}, + "mode": "BENCHMARK", + "strategicFocus": ["приоритет 1", "приоритет 2", "приоритет 3"] + }, + "10_structured_conclusions": ["вывод 1", "вывод 2", "вывод 3", "вывод 4", "вывод 5"], + "11_smm_strategy_rationale": "Минимум 80 слов с конкретными цифрами." + } + + ОТВЕТ: только JSON. Никакого текста вне JSON. + """.formatted( + // Профиль клиента + sanitize(request.getBusinessNiche()), + sanitize(request.getProductName()), + sanitize(request.getGoal()), + sanitize(request.getProductDescription()), + businessStageDesc, + clientTargetDesc, + offerTypeDesc, + avgCheckDesc, + customerBehaviorsDesc, + geoScopeDesc, + sanitize(request.getMainCity()), + String.join(", ", safeList(request.getPresenceCities())), + String.join(", ", cities), + purchaseFreqDesc, + visualFactorDesc, + decisionPrioritiesDesc, + discoveryMethodsDesc, + priceFeedbackDesc, + smmStatusDesc, + leadVolumeDesc, + responseHandlerDesc, + constraintsDesc, + String.join(", ", safeList(request.getUserSocialLinks())), + String.join(", ", safeList(request.getKnownCompetitorLinks())), + // Evidence block + evidence, + // Требования к блокам + cityDistJson, + cityCompJson, + positioningNote, + // Параметры для rationale + offerTypeDesc, + clientTargetDesc, + visualFactorDesc, + // JSON шаблон городов + cityDistJson, + cityCompJson + ); + } catch (Exception e) { + throw new RuntimeException("Failed to build user prompt", e); + } + } + + // ===================================================================== + // STEP 4 — AI CALL WITH RETRY + // ===================================================================== + + private String generateWithRetry(String docId, String systemPrompt, String userPrompt) { int maxAttempts = 3; String lastError = ""; - while (attempts < maxAttempts) { + for (int attempt = 1; attempt <= maxAttempts; attempt++) { try { - String response = aiService.generateWithInstructionWithModel("{}", userPrompt, "ru", highIntelligenceModel, systemPrompt, 16000, 240000L); - String cleaned = cleanJson(response); - if (cleaned != null && cleaned.startsWith("{") && cleaned.endsWith("}")) { - objectMapper.readTree(cleaned); - return cleaned; - } else { - lastError = "Response is not a valid JSON structure"; + log.info("[{}] AI attempt {}/{}", docId, attempt, maxAttempts); + + String raw = aiService.generateWithInstructionWithModel( + "{}", userPrompt, "ru", highIntelligenceModel, + systemPrompt, 16000, 240_000L + ); + + if (raw == null || raw.isBlank()) { + lastError = "Empty response from AI"; + log.warn("[{}] Attempt {} — empty response", docId, attempt); + sleep(3000L * attempt); + continue; } + + String cleaned = extractJson(raw); + if (cleaned == null || !cleaned.startsWith("{") || !cleaned.endsWith("}")) { + lastError = "Response is not a valid JSON object"; + log.warn("[{}] Attempt {} — bad JSON structure, starts with: {}", + docId, attempt, + cleaned != null ? cleaned.substring(0, Math.min(60, cleaned.length())) : "null"); + sleep(3000L * attempt); + continue; + } + + // Финальная проверка парсируемости + objectMapper.readTree(cleaned); + log.info("[{}] Attempt {} — valid JSON ({} chars)", docId, attempt, cleaned.length()); + return cleaned; + } catch (Exception e) { - lastError = "API Error: " + e.getMessage(); + lastError = e.getMessage(); + log.warn("[{}] Attempt {} — error: {}", docId, attempt, e.getMessage()); + sleep(3000L * attempt); } - attempts++; - try { Thread.sleep(3000L * attempts); } catch (InterruptedException ignored) {} } - throw new RuntimeException("Failed to generate valid JSON: " + lastError); + + throw new RuntimeException("AI failed after " + maxAttempts + " attempts. Last error: " + lastError); } - private MarketingAnalysisV3Result parseAndValidateResult(String json) throws Exception { - ObjectMapper safeMapper = this.objectMapper.copy(); - // ВАЖНО: Мы игнорируем старые поля, которые ИИ может по привычке вернуть - safeMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - return safeMapper.readValue(json, MarketingAnalysisV3Result.class); + private String extractJson(String raw) { + if (raw == null || raw.isBlank()) return null; + String s = raw.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); + s = s.trim(); + int first = s.indexOf('{'); + int last = s.lastIndexOf('}'); + if (first >= 0 && last > first) return s.substring(first, last + 1); + return s; } - private String cleanJson(String response) { - if (response == null || response.trim().isEmpty()) return null; - String cleaned = response.trim(); - int firstBrace = cleaned.indexOf("{"); - int lastBrace = cleaned.lastIndexOf("}"); - if (firstBrace != -1 && lastBrace != -1 && firstBrace <= lastBrace) { - return cleaned.substring(firstBrace, lastBrace + 1); - } - return cleaned; + // ===================================================================== + // PARSE + POST-PROCESS FIX + // ===================================================================== + + private MarketingAnalysisV3Result parseAndFix(String json) throws Exception { + ObjectMapper mapper = objectMapper.copy() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + MarketingAnalysisV3Result r = mapper.readValue(json, MarketingAnalysisV3Result.class); + fixConstraints(r); + return r; } + private void fixConstraints(MarketingAnalysisV3Result r) { + if (r == null) throw new IllegalStateException("Parsed result is null"); + + if (r.getExecutiveSummary() != null) { + var es = r.getExecutiveSummary(); + if (es.getAverageNicheEr() > 15.0) es.setAverageNicheEr(es.getAverageNicheEr() / 100.0); + es.setAverageNicheEr(clamp(es.getAverageNicheEr(), 0.0, 100.0)); + es.setAverageRating(clamp(es.getAverageRating(), 0.0, 5.0)); + } + + if (r.getCompetitorMap() != null) { + for (var cp : r.getCompetitorMap()) { + if (cp.getEr() > 15.0) cp.setEr(cp.getEr() / 100.0); + cp.setEr(clamp(cp.getEr(), 0.0, 100.0)); + cp.setRating(clamp(cp.getRating(), 0.0, 5.0)); + if (cp.getFollowers() < 0) cp.setFollowers(0); + if (cp.getPostsPerMonth() < 0) cp.setPostsPerMonth(0); + if (cp.getReviews() < 0) cp.setReviews(0); + } + } + + if (r.getReputationAnalysis() != null) { + var ra = r.getReputationAnalysis(); + ra.setAvgNicheRating(clamp(ra.getAvgNicheRating(), 0.0, 5.0)); + ra.setHighTrustBusinessShare(clamp(ra.getHighTrustBusinessShare(), 0.0, 100.0)); + if (ra.getAvgOwnerResponseTimeHours() < 0) ra.setAvgOwnerResponseTimeHours(0); + if (ra.getStarDistribution() != null && !ra.getStarDistribution().isEmpty()) { + double sum = ra.getStarDistribution().values().stream().mapToDouble(Double::doubleValue).sum(); + if (sum > 0 && Math.abs(sum - 100.0) > 0.5) { + final double fSum = sum; + ra.getStarDistribution().replaceAll((k, v) -> + Math.round((v / fSum * 100.0) * 10.0) / 10.0); + } + } + } + + if (r.getCompetitionIntensity() != null) { + r.getCompetitionIntensity().setCiiIndex( + (int) clamp(r.getCompetitionIntensity().getCiiIndex(), 0, 100)); + } + + if (r.getGeoStructure() != null) { + r.getGeoStructure().setDensityIndex( + clamp(r.getGeoStructure().getDensityIndex(), 0.0, 100.0)); + } + + if (r.getUserPositioning() != null) { + fixRadar(r.getUserPositioning().getMarketMetrics()); + fixRadar(r.getUserPositioning().getUserMetrics()); + } + + log.info("fixConstraints passed — result is physically valid"); + } + + private void fixRadar(MarketingAnalysisV3Result.RadarMetrics m) { + if (m == null) return; + m.setActivity( (int) clamp(m.getActivity(), 0, 100)); + m.setEngagement((int) clamp(m.getEngagement(), 0, 100)); + m.setVideo( (int) clamp(m.getVideo(), 0, 100)); + m.setReputation((int) clamp(m.getReputation(), 0, 100)); + m.setFrequency( (int) clamp(m.getFrequency(), 0, 100)); + } + + private double clamp(double val, double min, double max) { + return Math.max(min, Math.min(max, val)); + } + + // ===================================================================== + // DB HELPERS + // ===================================================================== + private void updateStatus(String id, String status) { repository.findById(id).ifPresent(doc -> { doc.setStatus(status); @@ -190,14 +635,33 @@ public class MarketingAnalysisV3Service { }); } - private void saveResearchMetaData(String id, Map researchPack) { + private void saveResearchSnapshot(String id, Map results) { try { + Map snapshot = new LinkedHashMap<>(); + results.forEach((key, res) -> { + Map block = new LinkedHashMap<>(); + block.put("status", res.status()); + block.put("query", res.query()); + block.put("itemCount", res.items() == null ? 0 : res.items().size()); + if (res.items() != null) { + block.put("preview", res.items().stream().limit(3).map(i -> { + Map m = new LinkedHashMap<>(); + m.put("title", i.title()); + m.put("snippet", i.snippet()); + m.put("link", i.link()); + return m; + }).collect(Collectors.toList())); + } + snapshot.put(key, block); + }); + snapshot.put("generatedAt", LocalDateTime.now().toString()); + repository.findById(id).ifPresent(doc -> { - doc.setResearchMetaData(researchPack); + doc.setResearchMetaData(snapshot); repository.save(doc); }); } catch (Exception e) { - throw e; + log.warn("[{}] Failed to save research snapshot (non-critical): {}", id, e.getMessage()); } } @@ -219,172 +683,52 @@ public class MarketingAnalysisV3Service { }); } - public Optional getAnalysisById(String id) { - return repository.findById(id); - } + // ===================================================================== + // UTILS + // ===================================================================== - public List getAllByUser(String userId) { - return repository.findAllByUserIdOrderByCreatedAtDesc(userId); - } - - private String buildKazakhstanSystemPrompt() { - return """ - РОЛЬ: Ты — Chief Data Officer и Senior Стратег Big 4. - Твоя задача — сгенерировать ИДЕАЛЬНЫЙ датасет для дашборда. - - КРИТИЧЕСКИЕ ПРАВИЛА (СМЕРТЕЛЬНО ВАЖНО): - 1. НОВЫЕ КЛЮЧИ JSON: Используй ТОЛЬКО ключи из эталонного JSON ниже. КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО использовать старые ключи (avgTextLength, ctaFrequency, promoFrequency и т.д.). Используй ТОЛЬКО новые (avgTextLengthCharacters, ctaUsagePercent, promoUsagePercent). - 2. АБСОЛЮТНО НИКАКИХ NULL: Заполни КАЖДОЕ поле JSON. Если точной цифры нет — сгенерируй реалистичную оценку. НИ ОДНО ЗНАЧЕНИЕ НЕ ДОЛЖНО БЫТЬ null. - 3. ПРОТОКОЛ ВЫЖИВАНИЯ: Если SEARCH EVIDENCE пуст или содержит ошибки, ТЕБЕ ЗАПРЕЩАЕТСЯ писать об этом. Ты ОБЯЗАН сгенерировать 100% валидный JSON, используя метод Ферми и знания рынка. - 4. РУСИФИКАЦИЯ: Все текстовые значения (businessStage, demandTrend) СТРОГО на русском языке. - 5. ГЕОГРАФИЯ: Ты ОБЯЗАН включить КАЖДЫЙ город, переданный в запросе, в блоки 1_market_landscape и 2_geo_structure. - """; - } - - private String buildDataDrivenUserPrompt(MarketingAnalysisV3Request request, Map researchPack) { + /** + * Безопасно читает .getDescription() у любого enum с этим методом. + * Использует интерфейс-паттерн через лямбда — без кастов к конкретному типу. + */ + private > String descOrEmpty(T enumValue) { + if (enumValue == null) return ""; + // Все наши enum-ы имеют getDescription() — вызываем через reflection-free трюк try { - String requestJson = objectMapper.writeValueAsString(request); - String evidenceJson = objectMapper.writeValueAsString(researchPack); - String schemaTemplate = getJsonStructureTemplate(); - - List targetCitiesList = request.getPromotionCities() != null && !request.getPromotionCities().isEmpty() - ? request.getPromotionCities() - : (request.getPresenceCities() != null && !request.getPresenceCities().isEmpty() - ? request.getPresenceCities() - : List.of("Казахстан")); - String targetCities = String.join(", ", targetCitiesList); - - return """ - СФОРМИРУЙ ОТЧЕТ "MARKETING ANALYSIS V4.0" ДЛЯ РЫНКА КАЗАХСТАНА. - - ДАННЫЕ КЛИЕНТА (DTO): - %s - - РАЗВЕДДАННЫЕ (SEARCH EVIDENCE): - %s - - ЦЕЛЕВЫЕ ГОРОДА: %s - - ИНСТРУКЦИИ (СТРОГО СОБЛЮДАТЬ НОВЫЕ КЛЮЧИ БЕЗ ЕДИНОГО NULL): - - [4_content_profile] - - avgTextLengthCharacters: число (int). - - ctaUsagePercent: процент (double). - - [6_reputation_analysis] - - avgOwnerResponseTimeHours: часы (int). - - [7_behavioral_pattern] - - promoUsagePercent, directBookingPercent, priceVisibilityPercent: (double). - - avgCycleDays: (int). - - [8_search_demand] - - avgMonthlySearches: точное число запросов (int). - - [9_user_positioning] - - Если DTO smmStatus == "NONE": mode="BENCHMARK", userMetrics=0, marketMetrics=реальные числа. - - Если DTO smmStatus != "NONE": mode="COMPARISON", userMetrics=оценка, marketMetrics=реальные числа. - - ЭТАЛОННЫЙ JSON (СКОПИРУЙ КЛЮЧИ ТОЧЬ-В-ТОЧЬ, ЗАПОЛНИ ЧИСЛАМИ, НИКАКИХ NULL): - %s - """.formatted(requestJson, evidenceJson, targetCities, schemaTemplate); + return (String) enumValue.getClass().getMethod("getDescription").invoke(enumValue); } catch (Exception e) { - throw new RuntimeException("Error building prompt", e); + return enumValue.name(); } } - private String getJsonStructureTemplate() { - return """ - { - "0_executive_summary": { - "businessStage": "СТРОГО НА РУССКОМ", - "geography": "СТРОГО НА РУССКОМ", - "activeCompetitors": 0, - "competitionLevel": "СТРОГО НА РУССКОМ", - "averageNicheEr": 0.0, - "averageRating": 0.0, - "demandTrend": "СТРОГО НА РУССКОМ", - "keyFigures": ["string", "string", "string"] - }, - "1_market_landscape": { - "activePlayersByPlatform": {"Instagram": 0, "TikTok": 0, "GoogleMaps": 0}, - "cityDistribution": {"Город 1": 0}, - "nicheReputationLevel": 0.0 - }, - "2_geo_structure": { - "cityComparison": [{"city": "string", "activePlayers": 0, "avgEr": 0.0, "avgRating": 0.0, "avgPostsPerMonth": 0}], - "densityIndex": 0.0 - }, - "3_competitor_map": [ - { - "name": "РЕАЛЬНОЕ НАЗВАНИЕ БРЕНДА 1", - "platform": "string", - "followers": 0, - "postsPerMonth": 0, - "er": 0.0, - "rating": 0.0, - "reviews": 0, - "strengths": ["string"], - "weaknesses": ["string"] - } - ], - "4_content_profile": { - "demoContentPercent": 0.0, - "expertContentPercent": 0.0, - "salesContentPercent": 0.0, - "reviewsContentPercent": 0.0, - "engagementContentPercent": 0.0, - "videoShare": 0.0, - "avgTextLengthCharacters": 0, - "ctaUsagePercent": 0.0 - }, - "5_competition_intensity": { - "ciiIndex": 0, - "intensityLabel": "СТРОГО НА РУССКОМ", - "contributingFactors": ["string"] - }, - "6_reputation_analysis": { - "avgNicheRating": 0.0, - "medianReviews": 0, - "starDistribution": {"5": 0.0, "4": 0.0, "3": 0.0, "2": 0.0, "1": 0.0}, - "highTrustBusinessShare": 0.0, - "avgOwnerResponseTimeHours": 0 - }, - "7_behavioral_pattern": { - "promoUsagePercent": 0.0, - "directBookingPercent": 0.0, - "priceVisibilityPercent": 0.0, - "avgCycleDays": 0, - "commonCta": ["string"] - }, - "8_search_demand": { - "avgMonthlySearches": 0, - "seasonality": [ - {"period": "Январь", "value": 0.0}, - {"period": "Февраль", "value": 0.0}, - {"period": "Март", "value": 0.0}, - {"period": "Апрель", "value": 0.0}, - {"period": "Май", "value": 0.0}, - {"period": "Июнь", "value": 0.0}, - {"period": "Июль", "value": 0.0}, - {"period": "Август", "value": 0.0}, - {"period": "Сентябрь", "value": 0.0}, - {"period": "Октябрь", "value": 0.0}, - {"period": "Ноябрь", "value": 0.0}, - {"period": "Декабрь", "value": 0.0} - ], - "peakPeriods": ["string"], - "relatedQueries": ["string"] - }, - "9_user_positioning": { - "marketMetrics": {"activity": 0, "engagement": 0, "video": 0, "reputation": 0, "frequency": 0}, - "userMetrics": {"activity": 0, "engagement": 0, "video": 0, "reputation": 0, "frequency": 0}, - "mode": "BENCHMARK", - "strategicFocus": ["string", "string", "string"] - }, - "10_structured_conclusions": ["string", "string", "string"], - "11_smm_strategy_rationale": "МИНИМУМ 50 СЛОВ. Обоснование стратегии." - } - """; + /** + * Читает список enum-значений и возвращает их description через запятую. + */ + private > String listDesc(List list) { + if (list == null || list.isEmpty()) return "не указано"; + return list.stream() + .map(this::descOrEmpty) + .collect(Collectors.joining(", ")); + } + + private List safeList(List list) { + return list != null ? list : List.of(); + } + + private String sanitize(String input) { + if (input == null || input.isBlank()) return ""; + return input.trim().replaceAll("\\s+", " "); + } + + private List getTargetCities(MarketingAnalysisV3Request request) { + if (request.getPromotionCities() != null && !request.getPromotionCities().isEmpty()) + return request.getPromotionCities(); + if (request.getPresenceCities() != null && !request.getPresenceCities().isEmpty()) + return request.getPresenceCities(); + return List.of("Казахстан"); + } + + private void sleep(long ms) { + try { Thread.sleep(ms); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); } } } \ No newline at end of file diff --git a/src/main/java/kz/konturai/parser/service/MarketingStrategyV3Service.java b/src/main/java/kz/konturai/parser/service/MarketingStrategyV3Service.java index c0ed0f1..c24d68c 100644 --- a/src/main/java/kz/konturai/parser/service/MarketingStrategyV3Service.java +++ b/src/main/java/kz/konturai/parser/service/MarketingStrategyV3Service.java @@ -4,8 +4,10 @@ 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.ScoringResult; import kz.konturai.parser.dto.StatusHistoryEntry; import kz.konturai.parser.enums.StrategyModel; +import kz.konturai.parser.enums.StrategyModifier; import kz.konturai.parser.model.MarketingAnalysisV3Document; import kz.konturai.parser.model.MarketingStrategy; import kz.konturai.parser.model.PostingTask; @@ -14,8 +16,8 @@ import kz.konturai.parser.repository.MarketingStrategyRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.Lazy; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Lazy; import org.springframework.http.MediaType; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; @@ -24,22 +26,22 @@ import java.io.InputStream; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.*; +import java.util.stream.Collectors; @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 PostingTaskService postingTaskService; - private final MinIOService minIOService; + 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; - @Autowired - @Lazy + @Autowired @Lazy private MarketingStrategyV3Service self; private final ObjectMapper objectMapper = new ObjectMapper() @@ -51,6 +53,8 @@ public class MarketingStrategyV3Service { @Value("${image.generation.delayBetweenRequestsMs:5000}") private long delayBetweenRequestsMs; + + public Optional getStrategyById(String id) { return repository.findById(id); } @@ -63,10 +67,10 @@ public class MarketingStrategyV3Service { return repository.findByUserIdOrderByCreatedAtDesc(userId); } - public MarketingStrategy generateStrategy(String analysisId, MarketingStrategyRequest request, String userId, - List referenceFilenames) { + public MarketingStrategy generateStrategy(String analysisId, MarketingStrategyRequest request, + String userId, List referenceFilenames) { MarketingAnalysisV3Document analysisDoc = analysisRepository.findById(analysisId) - .orElseThrow(() -> new IllegalArgumentException("Analysis V3 not found")); + .orElseThrow(() -> new IllegalArgumentException("Analysis V3 not found: " + analysisId)); MarketingStrategy strategy = new MarketingStrategy(analysisId); strategy.setUserId(userId); @@ -80,173 +84,840 @@ public class MarketingStrategyV3Service { } strategy.setStrategyData(initialData); - addStatusHistoryEntry(strategy, "queued", "Запуск генерации. ИИ проектирует массированный контент-план..."); - + addStatusHistory(strategy, "queued", + "Запуск генерации. Система рассчитывает оптимальную стратегическую модель..."); strategy = repository.save(strategy); self.processStrategyGenerationAsync(strategy.getId(), analysisDoc, strategy); - return strategy; } + // ===================================================================== + // ASYNC PIPELINE + // ===================================================================== + @Async("reportGenerationExecutor") - public void processStrategyGenerationAsync(String strategyId, MarketingAnalysisV3Document analysis, + public void processStrategyGenerationAsync(String strategyId, + MarketingAnalysisV3Document analysis, MarketingStrategy strategy) { try { strategy.setStatus("processing"); - addStatusHistoryEntry(strategy, "processing", "Расчет баллов, генерация постов (минимум 3 в неделю)..."); + addStatusHistory(strategy, "processing", "Запускаю скоринговую систему..."); repository.save(strategy); - StrategyModel bestModel = calculateBestScoringModel(analysis.getRequestData()); + // Step 1: Scoring — строго по документу + ScoringResult scoring = calculateScoringModel(analysis.getRequestData(), analysis); - String systemPrompt = buildSystemPrompt(bestModel); - String userPrompt = buildUserPrompt(analysis, bestModel); + log.info("[Strategy {}] Scoring complete → model={} (E:{} Au:{} T:{} C:{}) modifiers={}", + strategyId, + scoring.getModel(), + scoring.getEntryScore(), scoring.getAuthorityScore(), + scoring.getTrustScore(), scoring.getConversionScore(), + scoring.getActiveModifiers().stream() + .map(StrategyModifier::getTitle) + .collect(Collectors.joining(", "))); + + addStatusHistory(strategy, "processing", + "✅ " + scoring.getRationale()); + repository.save(strategy); + + // Step 2: Build prompts + addStatusHistory(strategy, "processing", "Генерирую контент-план на 4 недели..."); + repository.save(strategy); + + String systemPrompt = buildSystemPrompt(scoring); + String userPrompt = buildUserPrompt(analysis, scoring); + + // Step 3: AI call + String rawResponse = aiService.generateWithInstructionWithModel( + "{}", userPrompt, "ru", highIntelligenceModel, + systemPrompt, 16000, 240_000L); - String rawResponse = aiService.generateWithInstructionWithModel("{}", userPrompt, "ru", - highIntelligenceModel, systemPrompt, 16000, 240000L); String jsonResponse = extractCleanJson(rawResponse); - if (jsonResponse == null) { - throw new IllegalStateException("AI failed to return valid JSON"); + throw new IllegalStateException("AI вернул невалидный JSON"); } - Map strategyData = objectMapper.readValue(jsonResponse, new TypeReference<>() { - }); + Map strategyData = objectMapper.readValue(jsonResponse, new TypeReference<>() {}); + // Сохраняем referenceFilenames из initialData if (strategy.getStrategyData() != null) { strategy.getStrategyData().forEach(strategyData::putIfAbsent); } + // Добавляем скоринг-метаданные для фронта + strategyData.put("scoringModel", scoring.getModel().name()); + strategyData.put("scoringModelTitle", scoring.getModel().getTitle()); + 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() + )); + populateStrategyEntity(strategy, strategyData); repository.save(strategy); - addStatusHistoryEntry(strategy, "processing", - "Создание фото и видео креативов. Это займет несколько минут..."); + // Step 4: Media generation + addStatusHistory(strategy, "processing", + "Создаю фото и видео креативы. Это займёт несколько минут..."); repository.save(strategy); generateMediaAssets(strategy, analysis); strategy.setStatus("completed"); strategy.setCompletedAt(LocalDateTime.now()); - addStatusHistoryEntry(strategy, "completed", "Стратегия и все медиа успешно созданы!"); + addStatusHistory(strategy, "completed", + "🎉 Стратегия готова! Модель: " + scoring.getModel().getTitle()); repository.save(strategy); + log.info("[Strategy {}] Completed. Model: {}, Posts: {}", + strategyId, scoring.getModel(), + strategy.getPostCalendar() != null ? strategy.getPostCalendar().size() : 0); + } catch (Exception e) { - log.error("Strategy generation error {}: {}", strategyId, e.getMessage(), e); + log.error("[Strategy {}] FAILED: {}", strategyId, e.getMessage(), e); markAsFailed(strategyId, "Ошибка при генерации стратегии: " + e.getMessage()); } } - public Map getStrategyResult(String strategyId) { - Optional optStrategy = repository.findById(strategyId); - if (optStrategy.isEmpty()) { - return null; + // ===================================================================== + // SCORING ENGINE — точное соответствие Scoring Model документу + // + // Факторы и веса (раздел 3 документа): + // 1. Поведение клиента — вес 3 (наивысший) + // 2. Средний чек — вес 2 + // 3. Этап бизнеса — вес 2 + // 4. B2B / B2C — вес 1 + // 5. Рыночная интенсивность — вес 1 (из анализа) + // ===================================================================== + + public ScoringResult calculateScoringModel( + kz.konturai.parser.dto.MarketingAnalysisV3Request req, + MarketingAnalysisV3Document analysis) { + + int entry = 0, authority = 0, trust = 0, conversion = 0; + List rationaleFactors = new ArrayList<>(); + + // ── A. Поведение клиента (вес 3) — таблица A документа ──────────── + // CustomerBehavior enum: BUY_QUICKLY, COMPARE_OPTIONS, REQUEST_PROPOSAL, + // CHECK_REVIEWS, NEED_CONSULTATION + 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("клиенты приходят на консультацию"); + } + } + } } - MarketingStrategy strategy = optStrategy.get(); - Map response = new HashMap<>(); - 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()); - response.put("strategyData", strategy.getStrategyData()); + // ── B. Средний чек (вес 2) — таблица B документа ───────────────── + // AverageCheck enum: LOW, MEDIUM, HIGH + 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("высокий средний чек"); + } + } + } - if (strategy.getWeeklyPlans() != null && strategy.getPostCalendar() != null) { - Map strategyContent = new HashMap<>(); - strategyContent.put("weeklyPlans", strategy.getWeeklyPlans()); + // ── C. Этап бизнеса (вес 2) — таблица C документа ─────────────── + // BusinessStage enum: JUST_STARTING, LESS_THAN_ONE_YEAR, + // ONE_TO_THREE_YEARS, MORE_THAN_THREE_YEARS + 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 лет"); + } + } + } - List> postCalendarList = new ArrayList<>(); - List tasks = postingTaskService.getStrategyTasks(strategyId); + // ── D. B2B / B2C (вес 1) — таблица D документа ─────────────────── + // ClientTarget enum: PRIVATE_CLIENTS (B2C), BUSINESS (B2B), MIXED + 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"); + } + } + } - List calendarItems = strategy.getPostCalendar(); - for (int i = 0; i < calendarItems.size(); i++) { - MarketingStrategy.PostCalendarItem item = calendarItems.get(i); - Map dtoItem = new HashMap<>(); + // ── E. Рыночная интенсивность (вес 1) — таблица E документа ────── + // Берём CII из результатов анализа если доступен + 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 + ")"); + } + } - dtoItem.put("postIndex", i); - dtoItem.put("publishDate", item.getPublishDate()); - dtoItem.put("platform", item.getPlatform()); - dtoItem.put("contentType", item.getContentType()); - dtoItem.put("theme", item.getTheme()); - dtoItem.put("postText", item.getPostText()); - dtoItem.put("hashtags", item.getHashtags()); - dtoItem.put("publishTime", item.getPublishTime()); - dtoItem.put("imageUrl", item.getImageUrl()); - dtoItem.put("imageFilename", item.getImageFilename()); - dtoItem.put("videoUrl", item.getVideoUrl()); - dtoItem.put("videoFilename", item.getVideoFilename()); + log.debug("Scoring raw: Entry={} Authority={} Trust={} Conversion={}", entry, authority, trust, conversion); - Optional matchingTask = tasks.stream() - .filter(task -> task.getPublishDate() != null && item.getPublishDate() != null - && task.getPublishDate().equals(item.getPublishDate()) - && task.getPlatform() != null && item.getPlatform() != null - && task.getPlatform().equalsIgnoreCase(item.getPlatform())) - .findFirst(); + // ── Выбор победителя (раздел 5 документа: ModelScore = Σ баллов) ── + int maxScore = Math.max(Math.max(entry, authority), Math.max(trust, conversion)); - if (matchingTask.isPresent()) { - dtoItem.put("taskId", matchingTask.get().getId()); - dtoItem.put("taskStatus", matchingTask.get().getStatus()); - } else { - dtoItem.put("taskStatus", null); + StrategyModel selectedModel = resolveModelWithTiebreaker( + entry, authority, trust, conversion, maxScore, req, analysis); + + // ── Активируем модификаторы (раздел из документа, макс. 3) ─────── + List modifiers = resolveModifiers(req, analysis); + + // ── Формируем объяснение для пользователя (раздел 9 документа) ─── + String rationale = buildRationaleText(selectedModel, rationaleFactors, + entry, authority, trust, conversion); + + return new ScoringResult(selectedModel, + entry, authority, trust, conversion, + modifiers, rationale); + } + + /** + * Разрешает победителя при равенстве баллов. + * Правила из раздела 6 документа: + * 1. Authority = Trust при высоком чеке → Authority + * 2. Trust = Conversion при высокой конкуренции → Trust + * 3. Entry = что-то при stage=launch → Entry + */ + private StrategyModel resolveModelWithTiebreaker( + int entry, int authority, int trust, int conversion, + int maxScore, + kz.konturai.parser.dto.MarketingAnalysisV3Request req, + MarketingAnalysisV3Document analysis) { + + boolean highCheck = req.getAverageCheck() != null + && req.getAverageCheck() == kz.konturai.parser.enums.AverageCheck.HIGH; + + boolean highCompetition = analysis != null && analysis.getResultData() != null + && analysis.getResultData().getCompetitionIntensity() != null + && analysis.getResultData().getCompetitionIntensity().getCiiIndex() >= 65; + + boolean isLaunch = req.getBusinessStage() != null + && req.getBusinessStage() == kz.konturai.parser.enums.BusinessStage.JUST_STARTING; + + // Правило 1: Authority = Trust, чек высокий → Authority + if (authority == maxScore && trust == maxScore && highCheck) { + log.debug("Tiebreaker #1 applied: Authority wins over Trust (high check)"); + return StrategyModel.AUTHORITY; + } + + // Правило 2: Trust = Conversion, высокая конкуренция → Trust + if (trust == maxScore && conversion == maxScore && highCompetition) { + log.debug("Tiebreaker #2 applied: Trust wins over Conversion (high competition)"); + return StrategyModel.TRUST; + } + + // Правило 3: Entry в равенстве при launch → Entry + if (entry == maxScore && isLaunch) { + log.debug("Tiebreaker #3 applied: Entry wins (stage=launch)"); + return StrategyModel.ENTRY; + } + + // Обычный выбор по максимуму + if (authority == maxScore) return StrategyModel.AUTHORITY; + if (conversion == maxScore) return StrategyModel.CONVERSION; + if (trust == maxScore) return StrategyModel.TRUST; + return StrategyModel.ENTRY; + } + + /** + * Определяет активные модификаторы. Порядок — по приоритету важности. + * Максимум 3 модификатора согласно документу. + */ + private List resolveModifiers( + kz.konturai.parser.dto.MarketingAnalysisV3Request req, + MarketingAnalysisV3Document analysis) { + + List mods = new ArrayList<>(); + + // 1. HIGH_COMPETITION — CII > 60 из анализа + if (mods.size() < 3 + && analysis != null && analysis.getResultData() != null + && analysis.getResultData().getCompetitionIntensity() != null + && analysis.getResultData().getCompetitionIntensity().getCiiIndex() > 60) { + mods.add(StrategyModifier.HIGH_COMPETITION); + } + + // 2. HIGH_CHECK — средний чек HIGH + if (mods.size() < 3 + && req.getAverageCheck() != null + && req.getAverageCheck() == kz.konturai.parser.enums.AverageCheck.HIGH) { + mods.add(StrategyModifier.HIGH_CHECK); + } + + // 3. SHORT_DECISION — клиенты покупают быстро + if (mods.size() < 3 + && req.getCustomerBehaviors() != null + && req.getCustomerBehaviors().contains( + kz.konturai.parser.enums.CustomerBehavior.BUY_QUICKLY)) { + mods.add(StrategyModifier.SHORT_DECISION); + } + + // 4. SOCIAL_PROOF_SENSITIVE — клиенты проверяют кейсы/отзывы + if (mods.size() < 3 + && req.getCustomerBehaviors() != null + && req.getCustomerBehaviors().contains( + kz.konturai.parser.enums.CustomerBehavior.CHECK_REVIEWS)) { + mods.add(StrategyModifier.SOCIAL_PROOF_SENSITIVE); + } + + // 5. RETENTION — регулярные покупки или подписка + if (mods.size() < 3 + && req.getPurchaseFrequency() != null + && req.getPurchaseFrequency() == kz.konturai.parser.enums.PurchaseFrequency.REGULARLY) { + mods.add(StrategyModifier.RETENTION); + } + + // 6. MULTI_GEO — несколько городов продвижения + 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); + } + } + + return mods; + } + + private String buildRationaleText(StrategyModel model, List factors, + int entry, int authority, int trust, int conversion) { + String topFactors = factors.stream() + .limit(3) + .collect(Collectors.joining(", ")); + + return String.format( + "Выбрана модель %s (баллы: Entry=%d, Authority=%d, Trust=%d, Conversion=%d). " + + "Причины: %s.", + model.getTitle(), entry, authority, trust, conversion, + topFactors.isEmpty() ? "анализ профиля бизнеса" : topFactors); + } + + // ===================================================================== + // PROMPT BUILDING + // ===================================================================== + + private String buildSystemPrompt(ScoringResult scoring) { + String modifiersBlock; + if (scoring.getActiveModifiers().isEmpty()) { + modifiersBlock = "Активных модификаторов нет — применяй базовую модель без корректировок."; + } else { + modifiersBlock = "ПРИМЕНИТЬ СЛЕДУЮЩИЕ МОДИФИКАТОРЫ К БАЗОВОЙ МОДЕЛИ:\n\n" + + scoring.getActiveModifiers().stream() + .map(StrategyModifier::getInstructions) + .collect(Collectors.joining("\n")); + } + + return """ + ТЫ — СТАРШИЙ SMM СТРАТЕГ РЫНКА КАЗАХСТАНА. + Скоринговая система рассчитала оптимальную модель на основе данных бизнеса. + + ════════════════════════════════════════════════ + ВЫБРАННАЯ МОДЕЛЬ: %s + ОБОСНОВАНИЕ: %s + ════════════════════════════════════════════════ + + ОБЯЗАТЕЛЬНЫЕ ПРАВИЛА МОДЕЛИ (НАРУШАТЬ ЗАПРЕЩЕНО): + %s + + ════════════════════════════════════════════════ + МОДИФИКАТОРЫ (%d активных): + %s + ════════════════════════════════════════════════ + + КРИТИЧЕСКИЕ ПРАВИЛА ГЕНЕРАЦИИ КОНТЕНТА: + + 1. КОЛИЧЕСТВО ПОСТОВ: + - 4 недели × минимум 3 поста = МИНИМУМ 12 постов в postCalendar + - Оптимально: 14-16 постов (равномерно распределить по неделям) + + 2. СТРОГОЕ СОБЛЮДЕНИЕ ПРОПОРЦИЙ: + Подсчитай итоговое количество постов и распредели типы контента + строго согласно процентам модели (расчёт в userPrompt). + + 3. ФОРМАТЫ: + - contentType принимает ТОЛЬКО два значения: "фото" или "видео" + - Соблюдай % видео/фото из правил модели + + 4. CTA В ПОСТАХ: + Используй ТОЛЬКО CTA-фразы из стиля своей модели (указаны в правилах выше) + + 5. ТЕКСТЫ ПОСТОВ (postText): + - Минимум 120 слов в каждом посте — реальный живой текст + - Пиши на русском языке, для аудитории Казахстана + - Конкретика, факты, польза — без воды и шаблонных фраз + - Эмодзи уместно, умеренно + + 6. ХЭШТЕГИ: + - 5-10 тематических хэштегов + 1-2 гео (#алматы / #астана / #казахстан) + - Чередовать популярные и нишевые + + 7. РАСПИСАНИЕ: + - Instagram: 09:00, 12:00, 18:00, 20:00 + - TikTok: 18:00, 20:00, 22:00 + - Telegram: 10:00, 13:00, 19:00 + - Не публиковать чаще 1 раза в день на одной платформе + + ВЫВОДИ ТОЛЬКО ЧИСТЫЙ JSON! Никакого текста до или после. + """.formatted( + scoring.getModel().getTitle(), + scoring.getRationale(), + scoring.getModel().getContentRules(), + scoring.getActiveModifiers().size(), + modifiersBlock + ); + } + + private String buildUserPrompt(MarketingAnalysisV3Document 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); + + return """ + ЗАДАНИЕ: Сгенерируй SMM-стратегию на 4 недели для рынка Казахстана. + + ══ ПРОФИЛЬ БИЗНЕСА ══ + %s + + ══ ДАННЫЕ РЫНКА (из маркетингового анализа) ══ + %s + + ══ ПАРАМЕТРЫ СТРАТЕГИИ ══ + Старт публикаций: %s + Выбранная модель: %s + Активные модификаторы: %s + + ══ ОБЯЗАТЕЛЬНЫЙ ПЛАН ПОСТОВ (рассчитан алгоритмом) ══ + %s + + ══ ТРЕБУЕМАЯ JSON СТРУКТУРА ══ + { + "selectedModel": "%s", + "scoringRationale": "%s", + "recommendedDurationWeeks": 4, + "recommendedPlatforms": ["Instagram", "TikTok"], + "rationale": "Обоснование выбора этих платформ для данного бизнеса (минимум 60 слов)", + "weeklyPlans": [ + { + "weekNumber": 1, + "mainThemes": ["Конкретная тема 1", "Конкретная тема 2", "Конкретная тема 3"], + "contentRecommendations": "Детальные рекомендации что снимать и писать на этой неделе (минимум 40 слов)", + "priorityPlatforms": ["Instagram"] + } + ], + "postCalendar": [ + { + "publishDate": "2024-01-15T10:00:00", + "platform": "Instagram", + "contentType": "фото", + "theme": "Конкретная тема поста — не шаблон", + "postText": "Полный текст поста минимум 120 слов. Живой язык, конкретика для Казахстана. CTA в конце поста.", + "hashtags": ["#хэштег1", "#хэштег2", "#алматы"], + "publishTime": "10:00" + } + ] } - postCalendarList.add(dtoItem); - } - strategyContent.put("postCalendar", postCalendarList); + НАПОМИНАНИЕ: postCalendar должен содержать МИНИМУМ 12 постов (4 недели × 3 поста). + ОТВЕТ: только JSON. Никакого текста вне JSON. + """.formatted( + clientJson, + analysisCtx, + startDate.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), + scoring.getModel().getTitle(), + scoring.getActiveModifiers().stream() + .map(StrategyModifier::getTitle) + .collect(Collectors.joining(", ")), + postCountPlan, + scoring.getModel().name(), + scoring.getRationale().replace("\"", "'") + ); + } - response.put("strategy", strategyContent); - response.put("postCalendar", postCalendarList); - response.put("weeklyPlans", strategy.getWeeklyPlans()); + /** + * Извлекает ключевые данные из результатов анализа для контекста AI. + * Чем больше конкретики — тем релевантнее контент-план. + */ + private String buildAnalysisContext(MarketingAnalysisV3Document analysis) { + if (analysis == null || analysis.getResultData() == null) { + return "Данные анализа недоступны — ориентируйся на профиль бизнеса."; } - return response; + var r = analysis.getResultData(); + var sb = new StringBuilder(); + + if (r.getExecutiveSummary() != null) { + var es = r.getExecutiveSummary(); + sb.append("Конкурентов в нише: ").append(es.getActiveCompetitors()).append("\n"); + sb.append("Уровень конкуренции: ").append(es.getCompetitionLevel()).append("\n"); + sb.append("Средний ER ниши: ").append(es.getAverageNicheEr()).append("%\n"); + sb.append("Средний рейтинг: ").append(es.getAverageRating()).append("/5.0\n"); + sb.append("Тренд спроса: ").append(es.getDemandTrend()).append("\n"); + if (es.getKeyFigures() != null && !es.getKeyFigures().isEmpty()) { + sb.append("Ключевые факты: ") + .append(String.join("; ", es.getKeyFigures())).append("\n"); + } + } + + if (r.getCompetitionIntensity() != null) { + sb.append("CII индекс: ").append(r.getCompetitionIntensity().getCiiIndex()).append("/100 — ") + .append(r.getCompetitionIntensity().getIntensityLabel()).append("\n"); + if (r.getCompetitionIntensity().getContributingFactors() != null) { + sb.append("Факторы конкуренции: ") + .append(String.join("; ", r.getCompetitionIntensity().getContributingFactors())).append("\n"); + } + } + + if (r.getContentProfile() != null) { + var cp = r.getContentProfile(); + sb.append("Доля видео у конкурентов: ").append(cp.getVideoShare()).append("%\n"); + sb.append("Средняя длина текста конкурентов: ").append(cp.getAvgTextLengthCharacters()).append(" симв.\n"); + sb.append("Используют CTA: ").append(cp.getCtaUsagePercent()).append("% конкурентов\n"); + } + + if (r.getBehavioralPattern() != null) { + var bp = r.getBehavioralPattern(); + sb.append("Акции используют: ").append(bp.getPromoUsagePercent()).append("% конкурентов\n"); + if (bp.getCommonCta() != null && !bp.getCommonCta().isEmpty()) { + sb.append("Популярные CTA у конкурентов: ") + .append(String.join(", ", bp.getCommonCta())).append("\n"); + } + } + + if (r.getSearchDemand() != null) { + sb.append("Поисковый спрос: ").append(r.getSearchDemand().getAvgMonthlySearches()) + .append(" запросов/мес\n"); + if (r.getSearchDemand().getPeakPeriods() != null && !r.getSearchDemand().getPeakPeriods().isEmpty()) { + sb.append("Пиковые периоды: ") + .append(String.join(", ", r.getSearchDemand().getPeakPeriods())).append("\n"); + } + } + + if (r.getStructuredConclusions() != null && !r.getStructuredConclusions().isEmpty()) { + sb.append("\nКлючевые выводы анализа:\n"); + r.getStructuredConclusions().forEach(c -> sb.append(" • ").append(c).append("\n")); + } + + if (r.getSmmStrategyRationale() != null && !r.getSmmStrategyRationale().isBlank()) { + sb.append("\nСтратегическое обоснование из анализа:\n") + .append(r.getSmmStrategyRationale()).append("\n"); + } + + return sb.toString(); } + /** + * Рассчитывает точное количество постов каждого типа. + * Даёт AI конкретный план вместо абстрактных процентов. + */ + private String buildPostCountPlan(StrategyModel model, int weeks) { + int total = weeks * 3; // минимум 3 поста/нед + int optimal = weeks * 4; // оптимально 4 поста/нед + + StringBuilder sb = new StringBuilder(); + sb.append("Минимум постов: ").append(total) + .append(" | Оптимально: ").append(optimal) + .append(" (").append(weeks).append(" недели)\n\n"); + + sb.append("Распределение по типам для ").append(total).append(" постов:\n"); + + switch (model) { + case ENTRY -> { + sb.append(" Объясняющий: ").append(pct(total, 0.30)).append(" постов (30%)\n"); + sb.append(" Демонстрационный: ").append(pct(total, 0.25)).append(" постов (25%)\n"); + sb.append(" Социальное доказательство: ").append(pct(total, 0.20)).append(" постов (20%)\n"); + sb.append(" Экспертный: ").append(pct(total, 0.15)).append(" постов (15%)\n"); + sb.append(" Продающий: ").append(pct(total, 0.10)).append(" постов (10%)\n"); + sb.append("\n Форматы: видео=").append(pct(total, 0.40)) + .append(", фото=").append(total - pct(total, 0.40)).append("\n"); + } + case AUTHORITY -> { + sb.append(" Экспертный: ").append(pct(total, 0.40)).append(" постов (40%)\n"); + sb.append(" Кейсы: ").append(pct(total, 0.25)).append(" постов (25%)\n"); + sb.append(" Объясняющий: ").append(pct(total, 0.15)).append(" постов (15%)\n"); + sb.append(" Социальное доказательство: ").append(pct(total, 0.10)).append(" постов (10%)\n"); + sb.append(" Продающий: ").append(pct(total, 0.10)).append(" постов (10%)\n"); + sb.append("\n Форматы: видео=").append(pct(total, 0.35)) + .append(", фото=").append(total - pct(total, 0.35)).append("\n"); + } + case TRUST -> { + sb.append(" Кейсы: ").append(pct(total, 0.30)).append(" постов (30%)\n"); + sb.append(" Отзывы клиентов: ").append(pct(total, 0.25)).append(" постов (25%)\n"); + sb.append(" Демонстрация процесса: ").append(pct(total, 0.20)).append(" постов (20%)\n"); + sb.append(" Экспертный: ").append(pct(total, 0.15)).append(" постов (15%)\n"); + sb.append(" Продающий: ").append(pct(total, 0.10)).append(" постов (10%)\n"); + sb.append("\n Форматы: видео=").append(pct(total, 0.50)) + .append(" (ПРИОРИТЕТ), фото=").append(total - pct(total, 0.50)).append("\n"); + } + case CONVERSION -> { + sb.append(" Продающий: ").append(pct(total, 0.35)).append(" постов (35%)\n"); + sb.append(" Демонстрационный: ").append(pct(total, 0.30)).append(" постов (30%)\n"); + sb.append(" Вовлекающий: ").append(pct(total, 0.20)).append(" постов (20%)\n"); + sb.append(" Социальное доказательство: ").append(pct(total, 0.10)).append(" постов (10%)\n"); + sb.append(" Объясняющий: ").append(pct(total, 0.05)).append(" постов (5%)\n"); + sb.append("\n Форматы: видео=").append(pct(total, 0.60)) + .append(" (ПРИОРИТЕТ), фото=").append(total - pct(total, 0.60)).append("\n"); + } + } + + return sb.toString(); + } + + private int pct(int total, double ratio) { + return (int) Math.round(total * ratio); + } + + // ===================================================================== + // MEDIA GENERATION + // ===================================================================== + + @SuppressWarnings("unchecked") + private void generateMediaAssets(MarketingStrategy strategy, + MarketingAnalysisV3Document analysis) { + String businessContext = getBusinessContext(analysis); + String brandName = analysis.getRequestData().getProductName(); + String niche = analysis.getRequestData().getBusinessNiche(); + + List refFiles = new ArrayList<>(); + if (strategy.getStrategyData() != null + && strategy.getStrategyData().containsKey("referenceFilenames")) { + refFiles = (List) strategy.getStrategyData().get("referenceFilenames"); + } + + Random random = new Random(); + int total = strategy.getPostCalendar().size(); + int current = 0; + + for (MarketingStrategy.PostCalendarItem item : strategy.getPostCalendar()) { + current++; + String contentType = item.getContentType() != null + ? item.getContentType().toLowerCase() : "фото"; + + log.info("[Media {}/{}] type={} theme={}", + current, total, contentType, item.getTheme()); + + // Загружаем референсный файл (логотип/стиль клиента) + byte[] refBytes = null; + if (!refFiles.isEmpty()) { + String selectedRef = refFiles.get(random.nextInt(refFiles.size())); + try (InputStream s = minIOService.downloadFile(selectedRef)) { + refBytes = s.readAllBytes(); + } catch (Exception e) { + log.warn("[Media] Failed to load ref file {}: {}", selectedRef, e.getMessage()); + } + } + + try { + if (contentType.contains("видео")) { + doGenerateVideo(item, businessContext, niche); + } else { + doGenerateImage(item, businessContext, brandName, niche, refBytes); + } + if (delayBetweenRequestsMs > 0) Thread.sleep(delayBetweenRequestsMs); + } catch (Exception e) { + log.error("[Media] Error for post '{}': {}", item.getTheme(), e.getMessage()); + } + } + } + + private void doGenerateVideo(MarketingStrategy.PostCalendarItem item, + String businessContext, String niche) { + String theme = item.getTheme() != null ? item.getTheme() : "Commercial content"; + String platform = item.getPlatform() != null ? item.getPlatform().toLowerCase() : "instagram"; + + String styleHint = platform.contains("tiktok") + ? "trendy TikTok style, fast cuts, text overlays, Gen-Z aesthetic, viral potential" + : "Instagram Reels style, aesthetic, smooth transitions, lifestyle feel, high-end"; + + String videoPrompt = String.format( + "High quality engaging vertical 9:16 video for %s business (niche: %s). " + + "Main theme: %s. Style: %s. " + + "Photorealistic, bright professional lighting, dynamic motion, brand-appropriate.", + businessContext, niche, theme, styleHint); + + try { + log.info("[VEO] Generating video for theme '{}'", theme); + byte[] videoBytes = geminiVideoService.generateVideo(videoPrompt); + + if (videoBytes != null && videoBytes.length > 0) { + String filename = "video_" + System.currentTimeMillis() + + "_" + Math.abs(item.hashCode()) + ".mp4"; + minIOService.uploadFile(filename, videoBytes, "video/mp4"); + item.setVideoUrl(filename); + item.setVideoFilename(filename); + item.setImageUrl(null); + item.setImageFilename(null); + log.info("[VEO] Saved as {}", filename); + } else { + log.warn("[VEO] Empty response for '{}'", theme); + item.setVideoFilename(null); + item.setVideoUrl(null); + } + } catch (Exception e) { + log.error("[VEO] Error for '{}': {}", theme, e.getMessage(), e); + item.setVideoFilename(null); + item.setVideoUrl(null); + } + } + + private void doGenerateImage(MarketingStrategy.PostCalendarItem item, + String businessContext, String brandName, + String niche, byte[] refBytes) { + String imagePrompt = buildImagePrompt(item, businessContext, brandName, niche); + try { + byte[] imageBytes = imageGenerationService.generateImageWithReference(imagePrompt, refBytes); + if (imageBytes != null && imageBytes.length > 0) { + String filename = "image_" + System.currentTimeMillis() + + "_" + Math.abs(item.hashCode()) + ".png"; + minIOService.uploadFile(filename, imageBytes, MediaType.IMAGE_PNG_VALUE); + item.setImageUrl(filename); + item.setImageFilename(filename); + item.setVideoUrl(null); + item.setVideoFilename(null); + } else { + log.warn("[Image] Empty response for theme: {}", item.getTheme()); + item.setImageFilename(null); + item.setImageUrl(null); + } + } catch (Exception e) { + log.error("[Image] Error for '{}': {}", item.getTheme(), e.getMessage()); + item.setImageFilename(null); + item.setImageUrl(null); + } + } + + private String buildImagePrompt(MarketingStrategy.PostCalendarItem item, + String businessContext, String brandName, String niche) { + StringBuilder prompt = new StringBuilder(); + String platform = item.getPlatform() != null ? item.getPlatform().toLowerCase() : "instagram"; + + if (platform.contains("linkedin")) { + prompt.append("Professional corporate photography, clean minimalist layout, " + + "business context, soft natural lighting, editorial style. "); + } else if (platform.contains("tiktok")) { + prompt.append("Bold vibrant colors, Gen-Z aesthetic, dynamic composition, " + + "trendy modern style, high contrast, eye-catching vertical format. "); + } else { + // Instagram default + prompt.append("High-end commercial photography, photorealistic, professional lighting, " + + "aesthetic Instagram-style, lifestyle feel, clean background. "); + } + + prompt.append("Business: ").append(businessContext).append(". "); + prompt.append("Niche: ").append(niche).append(". "); + + if (item.getTheme() != null && !item.getTheme().isBlank()) { + prompt.append("Visual concept: ").append(item.getTheme()).append(". "); + } + + prompt.append("Product/service is the main focus. People look natural and authentic. " + + "No text overlays. No watermarks. Brand: ").append(brandName).append("."); + + return prompt.toString(); + } + + // ===================================================================== + // REGENERATION + // ===================================================================== + @SuppressWarnings("unchecked") public MarketingStrategy.PostCalendarItem regeneratePostImage(String strategyId, int postIndex) { - Optional optStrategy = repository.findById(strategyId); - if (optStrategy.isEmpty()) { - return null; - } + MarketingStrategy strategy = repository.findById(strategyId).orElse(null); + if (strategy == null) return null; - MarketingStrategy strategy = optStrategy.get(); - List postCalendar = strategy.getPostCalendar(); + List calendar = strategy.getPostCalendar(); + if (calendar == null || postIndex < 0 || postIndex >= calendar.size()) return null; - if (postCalendar == null || postIndex < 0 || postIndex >= postCalendar.size()) { - return null; - } - - MarketingStrategy.PostCalendarItem item = postCalendar.get(postIndex); + MarketingStrategy.PostCalendarItem item = calendar.get(postIndex); item.setContentType("фото"); - Optional analysisOpt = analysisRepository.findById(strategy.getAnalysisId()); - if (analysisOpt.isEmpty()) { - return null; - } + MarketingAnalysisV3Document analysis = analysisRepository + .findById(strategy.getAnalysisId()).orElse(null); + if (analysis == null) return null; - MarketingAnalysisV3Document analysis = analysisOpt.get(); - String businessContext = getBusinessContext(analysis); - String brandName = analysis.getRequestData().getProductName(); - - byte[] clientRefBytes = null; - if (strategy.getStrategyData() != null && strategy.getStrategyData().containsKey("referenceFilenames")) { + byte[] refBytes = null; + if (strategy.getStrategyData() != null + && strategy.getStrategyData().containsKey("referenceFilenames")) { List refFiles = (List) strategy.getStrategyData().get("referenceFilenames"); if (refFiles != null && !refFiles.isEmpty()) { String randomRef = refFiles.get(new Random().nextInt(refFiles.size())); - try { - InputStream logoStream = minIOService.downloadFile(randomRef); - clientRefBytes = logoStream.readAllBytes(); - logoStream.close(); + try (InputStream s = minIOService.downloadFile(randomRef)) { + refBytes = s.readAllBytes(); } catch (Exception e) { - log.error("Failed to download ref file for regeneration: {}", e.getMessage()); + log.warn("[Regen] Failed to load ref file: {}", e.getMessage()); } } } - doGenerateImage(item, businessContext, brandName, clientRefBytes); + doGenerateImage(item, + getBusinessContext(analysis), + analysis.getRequestData().getProductName(), + analysis.getRequestData().getBusinessNiche(), + refBytes); repository.save(strategy); return item; } @@ -254,146 +925,130 @@ public class MarketingStrategyV3Service { @Async("reportGenerationExecutor") public void regeneratePostVideoAsync(String strategyId, int postIndex) { try { - Optional optStrategy = repository.findById(strategyId); - if (optStrategy.isEmpty()) { - log.error("Регенерация видео отменена: стратегия {} не найдена", strategyId); - return; + MarketingStrategy strategy = repository.findById(strategyId).orElse(null); + if (strategy == null) { + log.error("[VideoRegen] Strategy {} not found", strategyId); return; } - MarketingStrategy strategy = optStrategy.get(); - List postCalendar = strategy.getPostCalendar(); - - if (postCalendar == null || postIndex < 0 || postIndex >= postCalendar.size()) { - log.error("Регенерация видео отменена: неверный postIndex {}", postIndex); - return; + List calendar = strategy.getPostCalendar(); + if (calendar == null || postIndex < 0 || postIndex >= calendar.size()) { + log.error("[VideoRegen] Invalid postIndex {}", postIndex); return; } - MarketingStrategy.PostCalendarItem item = postCalendar.get(postIndex); + MarketingStrategy.PostCalendarItem item = calendar.get(postIndex); item.setContentType("видео"); - item.setVideoUrl("generating..."); item.setVideoFilename(null); item.setImageUrl(null); item.setImageFilename(null); repository.save(strategy); - Optional analysisOpt = analysisRepository.findById(strategy.getAnalysisId()); - if (analysisOpt.isEmpty()) { - log.error("Регенерация видео отменена: анализ не найден"); - return; + MarketingAnalysisV3Document analysis = analysisRepository + .findById(strategy.getAnalysisId()).orElse(null); + if (analysis == null) { + log.error("[VideoRegen] Analysis not found"); return; } - MarketingAnalysisV3Document analysis = analysisOpt.get(); - String businessContext = getBusinessContext(analysis); - - log.info("Начинаем фоновую регенерацию видео для поста {} в стратегии {}", postIndex, strategyId); - - doGenerateVideo(item, businessContext); - + doGenerateVideo(item, + getBusinessContext(analysis), + analysis.getRequestData().getBusinessNiche()); repository.save(strategy); - log.info("Фоновая регенерация видео для поста {} успешно завершена!", postIndex); + log.info("[VideoRegen] Done for post {} in strategy {}", postIndex, strategyId); } catch (Exception e) { - log.error("Ошибка при фоновой регенерации видео: {}", e.getMessage(), e); + log.error("[VideoRegen] Failed: {}", e.getMessage(), e); } } - public StrategyModel calculateBestScoringModel(kz.konturai.parser.dto.MarketingAnalysisV3Request req) { - int entry = 0, authority = 0, trust = 0, conversion = 0; + // ===================================================================== + // STRATEGY RESULT + // ===================================================================== - 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; - } + public Map getStrategyResult(String strategyId) { + MarketingStrategy strategy = repository.findById(strategyId).orElse(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("priorityPlatforms", strategy.getPriorityPlatforms()); + response.put("strategyData", strategy.getStrategyData()); + + if (strategy.getWeeklyPlans() != null && strategy.getPostCalendar() != null) { + List tasks = postingTaskService.getStrategyTasks(strategyId); + + List> postCalendarList = new ArrayList<>(); + List items = strategy.getPostCalendar(); + + 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("imageFilename", item.getImageFilename()); + dto.put("videoUrl", item.getVideoUrl()); + dto.put("videoFilename", item.getVideoFilename()); + + tasks.stream() + .filter(t -> t.getPublishDate() != null && item.getPublishDate() != null + && t.getPublishDate().equals(item.getPublishDate()) + && t.getPlatform() != null && item.getPlatform() != null + && t.getPlatform().equalsIgnoreCase(item.getPlatform())) + .findFirst() + .ifPresentOrElse( + 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(), + "postCalendar", postCalendarList)); + response.put("postCalendar", postCalendarList); + response.put("weeklyPlans", strategy.getWeeklyPlans()); } - 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; - } - } + return response; + } - 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; - } - } + // ===================================================================== + // HELPERS + // ===================================================================== - 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; + private String getBusinessContext(MarketingAnalysisV3Document analysis) { + if (analysis == null || analysis.getRequestData() == null) return ""; + return analysis.getRequestData().getBusinessNiche() + + ", " + analysis.getRequestData().getProductName(); } @SuppressWarnings("unchecked") - private void populateStrategyEntity(MarketingStrategy strategy, Map strategyData) { - strategy.setStrategyData(strategyData); + private void populateStrategyEntity(MarketingStrategy strategy, Map data) { + strategy.setStrategyData(data); - if (strategyData.containsKey("recommendedDurationWeeks")) { - strategy.setDurationWeeks((Integer) strategyData.get("recommendedDurationWeeks")); + if (data.containsKey("recommendedDurationWeeks")) { + strategy.setDurationWeeks((Integer) data.get("recommendedDurationWeeks")); } - if (strategyData.containsKey("recommendedPlatforms")) { - strategy.setPriorityPlatforms((List) strategyData.get("recommendedPlatforms")); + if (data.containsKey("recommendedPlatforms")) { + strategy.setPriorityPlatforms((List) data.get("recommendedPlatforms")); } - List> weeksMap = (List>) strategyData.get("weeklyPlans"); + // Weekly plans + List> weeksRaw = (List>) data.get("weeklyPlans"); List weeklyPlans = new ArrayList<>(); - if (weeksMap != null) { - for (Map w : weeksMap) { + if (weeksRaw != null) { + for (Map w : weeksRaw) { MarketingStrategy.WeeklyPlan plan = new MarketingStrategy.WeeklyPlan(); plan.setWeekNumber((Integer) w.get("weekNumber")); plan.setMainThemes((List) w.get("mainThemes")); @@ -404,18 +1059,20 @@ public class MarketingStrategyV3Service { } strategy.setWeeklyPlans(weeklyPlans); - List> calendarMap = (List>) strategyData.get("postCalendar"); + // Post calendar + List> calendarRaw = (List>) data.get("postCalendar"); List postCalendar = new ArrayList<>(); LocalDateTime startDate = LocalDateTime.now().plusDays(1); - if (calendarMap != null) { - for (Map c : calendarMap) { + if (calendarRaw != null) { + for (Map c : calendarRaw) { MarketingStrategy.PostCalendarItem item = new MarketingStrategy.PostCalendarItem(); String dateStr = (String) c.get("publishDate"); if (dateStr != null) { try { - item.setPublishDate(LocalDateTime.parse(dateStr, DateTimeFormatter.ISO_LOCAL_DATE_TIME)); + item.setPublishDate( + LocalDateTime.parse(dateStr, DateTimeFormatter.ISO_LOCAL_DATE_TIME)); } catch (Exception e) { item.setPublishDate(startDate.plusDays(postCalendar.size() * 2L)); } @@ -434,244 +1091,32 @@ public class MarketingStrategyV3Service { } } strategy.setPostCalendar(postCalendar); - } - @SuppressWarnings("unchecked") - private void generateMediaAssets(MarketingStrategy strategy, MarketingAnalysisV3Document analysis) { - String businessContext = getBusinessContext(analysis); - String brandName = analysis.getRequestData().getProductName(); - - List refFiles = new ArrayList<>(); - if (strategy.getStrategyData() != null && strategy.getStrategyData().containsKey("referenceFilenames")) { - refFiles = (List) strategy.getStrategyData().get("referenceFilenames"); - } - - Random random = new Random(); - - for (MarketingStrategy.PostCalendarItem item : strategy.getPostCalendar()) { - String contentType = item.getContentType() != null ? item.getContentType().toLowerCase() : "фото"; - - byte[] currentRefBytes = null; - if (!refFiles.isEmpty()) { - String selectedRef = refFiles.get(random.nextInt(refFiles.size())); - try { - InputStream logoStream = minIOService.downloadFile(selectedRef); - currentRefBytes = logoStream.readAllBytes(); - logoStream.close(); - } catch (Exception e) { - log.error("Failed to download ref file {}: {}", selectedRef, e.getMessage()); - } - } - - try { - if (contentType.contains("видео") || contentType.contains("reels") || contentType.contains("tiktok")) { - doGenerateVideo(item, businessContext); - } else { - doGenerateImage(item, businessContext, brandName, currentRefBytes); - } - - if (delayBetweenRequestsMs > 0) - Thread.sleep(delayBetweenRequestsMs); - - } catch (Exception e) { - log.error("Error creating media for post '{}': {}", item.getTheme(), e.getMessage()); - } - } - } - - // === ВОТ ЗДЕСЬ ОБНОВЛЕННЫЙ ПРОМПТ ДЛЯ ВИДЕО === - private void doGenerateVideo(MarketingStrategy.PostCalendarItem item, String businessContext) { - String theme = item.getTheme() != null ? item.getTheme() : "Commercial content"; - // Изменили промпт: убрали 4K, добавили стиль TikTok/Instagram Reels, сделали его более оптимизированным - String videoPrompt = String.format( - "High quality engaging vertical video for %s. Scenario: %s. Photorealistic, bright lighting, trendy TikTok and Instagram Reels style, dynamic motion.", - businessContext, theme); - - try { - log.info("VEO: Generating video. Prompt: '{}'", videoPrompt); - 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); - item.setImageUrl(null); - item.setImageFilename(null); - log.info("VEO: Video generated successfully: {}", filename); - } else { - log.error( - "VEO: Video generation returned null. Possible cause: extraction failed or API returned empty for prompt: '{}'", - videoPrompt); - item.setVideoFilename(null); - item.setVideoUrl(null); - } - } catch (Exception e) { - log.error("VEO: Critical error in video generation: {}", e.getMessage(), e); - item.setVideoFilename(null); - item.setVideoUrl(null); - } - } - // =============================================== - - private void doGenerateImage(MarketingStrategy.PostCalendarItem item, String businessContext, String brandName, - byte[] clientRefBytes) { - String imagePrompt = buildImagePrompt(item, businessContext, brandName); - try { - byte[] imageBytes = imageGenerationService.generateImageWithReference(imagePrompt, clientRefBytes); - if (imageBytes != null && imageBytes.length > 0) { - String filename = "image_" + System.currentTimeMillis() + "_" + item.hashCode() + ".png"; - minIOService.uploadFile(filename, imageBytes, MediaType.IMAGE_PNG_VALUE); - item.setImageUrl(filename); - item.setImageFilename(filename); - item.setVideoUrl(null); - item.setVideoFilename(null); - } else { - log.error("Image generation returned null for theme: {}", item.getTheme()); - item.setImageFilename(null); - item.setImageUrl(null); - } - } catch (Exception e) { - log.error("Image generation crashed: {}", e.getMessage()); - item.setImageFilename(null); - item.setImageUrl(null); - } - } - - private String getBusinessContext(MarketingAnalysisV3Document analysis) { - if (analysis == null || analysis.getRequestData() == null) - return ""; - return analysis.getRequestData().getBusinessNiche() + ", product: " - + analysis.getRequestData().getProductName(); - } - - private String buildImagePrompt(MarketingStrategy.PostCalendarItem item, String businessContext, String brandName) { - StringBuilder prompt = new StringBuilder(); - - String style = "high-end commercial photography, photorealistic, 8k resolution, highly detailed, professional lighting"; - if (item.getPlatform() != null) { - String platform = item.getPlatform().toLowerCase(); - if (platform.contains("linkedin")) { - style += ", corporate style, professional, minimalist, clean"; - } else if (platform.contains("instagram") || platform.contains("tiktok")) { - style += ", lifestyle, aesthetic, vibrant colors, engaging, instagram-style"; - } - } - - prompt.append(style).append(". "); - - if (businessContext != null && !businessContext.isEmpty()) { - prompt.append("Context: ").append(businessContext).append(". "); - } - - if (item.getTheme() != null && !item.getTheme().isEmpty()) { - prompt.append("Main subject/action: ").append(item.getTheme()).append(". "); - } - - prompt.append("Composition: Product or service is the main focus. People (if any) should look natural. "); - - return prompt.toString(); - } - - private String buildSystemPrompt(StrategyModel bestModel) { - return """ - РОЛЬ: Ты — топовый Голливудский Креативный Директор и Chief Media Planner. - Бэкенд-система произвела расчет и выбрала доминирующую стратегию: %s. - - Твоя задача — проанализировать нишу и САМОСТОЯТЕЛЬНО: - 1. Выбрать наиболее эффективные платформы (от 1 до 3 шт). - 2. Определить оптимальную длительность стратегии (рекомендую 4 недели). - 3. Сгенерировать МАСШТАБНЫЙ контент-план. - - КРИТИЧЕСКОЕ ПРАВИЛО МАТЕМАТИКИ: - Ты ОБЯЗАН сгенерировать МИНИМУМ 3 поста на КАЖДУЮ неделю. - Если ты выбрал длительность 4 недели, в массиве 'postCalendar' должно быть РОВНО 12 постов (или больше). - Обязательно чередуй типы контента: 'фото' и 'видео'. - - ПРАВИЛА МОДЕЛИ: - %s - - ВЫВОДИ ТОЛЬКО ЧИСТЫЙ JSON! Никакого лишнего текста. - """.formatted(bestModel.getTitle(), bestModel.getContentRules()); - } - - private String buildUserPrompt(MarketingAnalysisV3Document analysis, StrategyModel bestModel) throws Exception { - String clientDtoJson = objectMapper.writeValueAsString(analysis.getRequestData()); - LocalDateTime startDate = LocalDateTime.now().plusDays(1); - - return """ - ДАННЫЕ БИЗНЕСА: - %s - - Старт публикаций: %s - ВЫБРАННАЯ МОДЕЛЬ: %s - - ВЫВЕДИ ТОЛЬКО JSON СТРОГО ПО ЭТОЙ СТРУКТУРЕ (СГЕНЕРИРУЙ 12 ПОСТОВ ЕСЛИ ВЫБРАЛ 4 НЕДЕЛИ): - { - "selectedModel": "%s", - "rationale": "Обоснование выбора платформ", - "recommendedDurationWeeks": 4, - "recommendedPlatforms": ["Instagram", "TikTok"], - "weeklyPlans": [ - { - "weekNumber": 1, - "mainThemes": ["Тема 1", "Тема 2", "Тема 3"], - "contentRecommendations": "Что снимать и писать", - "priorityPlatforms": ["Instagram"] - } - ], - "postCalendar": [ - { - "publishDate": "2024-01-15T10:00:00", - "platform": "Instagram", - "contentType": "фото", - "theme": "Тема первого поста", - "postText": "Текст поста...", - "hashtags": ["#тег"], - "publishTime": "10:00" - }, - { - "publishDate": "2024-01-17T14:00:00", - "platform": "TikTok", - "contentType": "видео", - "theme": "Тема второго поста", - "postText": "Текст поста...", - "hashtags": ["#тег"], - "publishTime": "14:00" - } - ] - } - """.formatted( - clientDtoJson, - startDate.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), - bestModel.name(), - bestModel.name()); + log.info("Populated: {} weeklyPlans, {} postCalendar items", + weeklyPlans.size(), postCalendar.size()); } private String extractCleanJson(String response) { - if (response == null || response.trim().isEmpty()) - return null; - String cleaned = response.trim(); - - int startIdx = cleaned.indexOf("{"); - int endIdx = cleaned.lastIndexOf("}"); - - if (startIdx >= 0 && endIdx > startIdx) { - return cleaned.substring(startIdx, endIdx + 1); - } - 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); + s = s.trim(); + int first = s.indexOf('{'); + int last = s.lastIndexOf('}'); + return (first >= 0 && last > first) ? s.substring(first, last + 1) : null; } - private void addStatusHistoryEntry(MarketingStrategy strategy, String status, String message) { - if (strategy.getStatusHistory() == null) - strategy.setStatusHistory(new ArrayList<>()); + private void addStatusHistory(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); + addStatusHistory(s, "failed", errorMsg); repository.save(s); }); }