This commit is contained in:
arys
2026-03-08 21:29:14 +05:00
parent f5f449c4f7
commit 8125cc0d05
7 changed files with 2002 additions and 935 deletions
@@ -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<String, String> 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<MarketingAnalysisV3Document> 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<byte[]> 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<Resource> 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<MarketingAnalysisV3Document> 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<String, Object> responseData = Map.of(
"recommendedModel", recommendedModel.name(),
"modelTitle", recommendedModel.getTitle(),
"description", "ИИ автоматически подберет идеальные платформы и длительность контент-плана для этой стратегии."
);
// Используем новый метод: передаём и request, и analysis (для CII и рыночной интенсивности)
ScoringResult scoring = strategyService.calculateScoringModel(
analysis.getRequestData(), analysis);
Map<String, Object> 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<MultipartFile> referenceFiles
) {
@RequestPart(value = "request", required = false) MarketingStrategyRequest request,
@RequestPart(value = "references", required = false) List<MultipartFile> 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<String> 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<String, String> 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<StrategyHistoryResponse> 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<PostingTask> tasks = postingTaskService.createTasksFromStrategy(strategyId);
List<String> platforms = tasks.stream().map(PostingTask::getPlatform).distinct().collect(Collectors.toList());
List<PostingTask> tasks = postingTaskService.createTasksFromStrategy(strategyId);
List<String> 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<PostingTask> updatedTask = postingTaskService.getTaskById(taskId);
if (updatedTask.isPresent()) {
PostingTask taskData = updatedTask.get();
Map<String, Object> responseData = Map.of(
"taskId", taskData.getId(),
"status", taskData.getStatus(),
"platform", taskData.getPlatform(),
"publishDate", taskData.getPublishDate()
);
return ResponseEntity.ok(ApiResponse.success("Задача успешно запущена", responseData));
Optional<PostingTask> 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<MarketingStrategy> 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<String, Object> 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<MarketingStrategy> 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<String, Object> 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<ApiResponse<ErrorResponse>> handleValidationException(MethodArgumentNotValidException ex) {
public ResponseEntity<ApiResponse<ErrorResponse>> handleValidationException(
MethodArgumentNotValidException ex) {
Map<String, String> 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<ApiResponse<Object>> 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<ApiResponse<Object>> 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<ApiResponse<Object>> 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<ApiResponse<Object>> 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())));
}
}
@@ -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<String> 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<String> keyFigures = List.of();
}
@Data
public static class MarketLandscape {
private Map<String, Integer> activePlayersByPlatform;
private Map<String, Integer> cityDistribution;
private double nicheReputationLevel;
private Map<String, Integer> activePlayersByPlatform = Map.of();
private Map<String, Integer> cityDistribution = Map.of();
private double nicheReputationLevel = 0.0;
}
@Data
public static class GeoStructure {
private List<CityMetrics> cityComparison;
private double densityIndex;
private List<CityMetrics> 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<String> strengths;
private List<String> 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<String> strengths = List.of();
private List<String> 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<String> contributingFactors;
private int ciiIndex = 0;
private String intensityLabel = "";
private List<String> contributingFactors = List.of();
}
@Data
public static class ReputationAnalysis {
private double avgNicheRating;
private int medianReviews;
private Map<String, Double> starDistribution;
private double highTrustBusinessShare;
private int avgOwnerResponseTimeHours;
private double avgNicheRating = 0.0;
private int medianReviews = 0;
private Map<String, Double> 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<String> commonCta;
private double promoUsagePercent = 0.0;
private double directBookingPercent = 0.0;
private double priceVisibilityPercent = 0.0;
private int avgCycleDays = 0;
private List<String> commonCta = List.of();
}
@Data
public static class SearchDemand {
private int avgMonthlySearches;
private List<TimeSeriesPoint> seasonality;
private List<String> peakPeriods;
private List<String> relatedQueries;
private int avgMonthlySearches = 0;
private List<TimeSeriesPoint> seasonality = List.of();
private List<String> peakPeriods = List.of();
private List<String> relatedQueries = List.of();
}
@Data
public static class UserPositioning {
private RadarMetrics marketMetrics;
private RadarMetrics userMetrics;
private String mode;
private List<String> strategicFocus;
private RadarMetrics marketMetrics = new RadarMetrics();
private RadarMetrics userMetrics = new RadarMetrics();
private String mode = "BENCHMARK";
private List<String> 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;
}
}
@@ -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<StrategyModifier> activeModifiers;
private String rationale;
}
@@ -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; }
}
@@ -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;
}
@@ -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<MarketingAnalysisV3Document> getAnalysisById(String id) {
return repository.findById(id);
}
public List<MarketingAnalysisV3Document> 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<String, Object> researchPack = executeDeepResearch(request);
saveResearchMetaData(docId, researchPack);
// Step 1: Search
log.info("[{}] Step 1/4: Deep Research", docId);
Map<String, SerperSearchResult> 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<String, Object> researchPack) {
if (researchPack == null || researchPack.isEmpty()) return false;
for (Map.Entry<String, Object> 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<String, SerperSearchResult> 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<String, Object> executeDeepResearch(MarketingAnalysisV3Request request) {
Map<String, Object> pack = new ConcurrentHashMap<>();
List<String> queries = new ArrayList<>();
List<String> cities = getTargetCities(request);
String geo = String.join(" ", cities);
String niche = sanitizeInput(request.getBusinessNiche());
String product = sanitizeInput(request.getProductName());
// Простые конкретные запросы — Serper работает надёжнее без OR-site-нагромождений
Map<String, String> 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<String> cities = request.getPromotionCities() != null && !request.getPromotionCities().isEmpty()
? request.getPromotionCities()
: (request.getPresenceCities() != null && !request.getPresenceCities().isEmpty()
? request.getPresenceCities()
: List.of("Казахстан"));
Map<String, SerperSearchResult> 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<String, SerperSearchResult> 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<String, SerperSearchResult> 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<SerperSearchItem> 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,000200,000.
- Постов в месяц: 4–25.
- avgMonthlySearches: узкая ниша 500–5,000/мес, популярная 10,00080,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<String> 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<String, Object> researchPack) {
private void saveResearchSnapshot(String id, Map<String, SerperSearchResult> results) {
try {
Map<String, Object> snapshot = new LinkedHashMap<>();
results.forEach((key, res) -> {
Map<String, Object> 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<String, String> 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<MarketingAnalysisV3Document> getAnalysisById(String id) {
return repository.findById(id);
}
// =====================================================================
// UTILS
// =====================================================================
public List<MarketingAnalysisV3Document> 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<String, Object> researchPack) {
/**
* Безопасно читает .getDescription() у любого enum с этим методом.
* Использует интерфейс-паттерн через лямбда — без кастов к конкретному типу.
*/
private <T extends Enum<T>> 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<String> 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 <T extends Enum<T>> String listDesc(List<T> list) {
if (list == null || list.isEmpty()) return "не указано";
return list.stream()
.map(this::descOrEmpty)
.collect(Collectors.joining(", "));
}
private List<String> safeList(List<String> 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<String> 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(); }
}
}
File diff suppressed because it is too large Load Diff