fix
This commit is contained in:
@@ -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,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<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
Reference in New Issue
Block a user