This commit is contained in:
arys
2026-02-19 20:39:18 +05:00
parent dc62166494
commit 63a1e39563
21 changed files with 1107 additions and 0 deletions
@@ -0,0 +1,153 @@
package kz.konturai.parser.controller;
import jakarta.validation.Valid;
import kz.konturai.parser.dto.ApiResponse;
import kz.konturai.parser.dto.ErrorResponse;
import kz.konturai.parser.dto.MarketingAnalysisV3Request;
import kz.konturai.parser.model.MarketingAnalysisV3Document;
import kz.konturai.parser.service.JwtService;
import kz.konturai.parser.service.MarketingAnalysisV3Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@RestController
@RequestMapping("/api/v3/marketing-analysis")
@RequiredArgsConstructor
@Slf4j
public class MarketingAnalysisV3Controller {
private final MarketingAnalysisV3Service service;
private final JwtService jwtService;
private String extractUserIdFromHeader(String authHeader) {
if (authHeader == null || authHeader.isEmpty()) {
log.debug("Authorization header is null or empty");
return null;
}
try {
String userId = jwtService.extractUserIdFromHeader(authHeader);
log.debug("Extracted userId from header: {}", userId);
return userId;
} catch (Exception e) {
log.error("Error extracting userId from header: {}", e.getMessage(), e);
return null;
}
}
@PostMapping("/start")
public ResponseEntity<?> startAnalysis(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@RequestBody @Valid MarketingAnalysisV3Request request
) {
String userId = extractUserIdFromHeader(authHeader);
if (userId == null) {
return unauthorizedResponse();
}
try {
String analysisId = service.createAndStartAnalysis(request, userId);
Map<String, String> responseData = Map.of(
"analysisId", analysisId,
"message", "Analysis V3 started successfully"
);
return ResponseEntity.accepted().body(ApiResponse.success("Анализ запущен", responseData));
} catch (Exception e) {
log.error("Failed to start analysis V3", e);
return internalErrorResponse(e);
}
}
@GetMapping("/{id}")
public ResponseEntity<?> getAnalysisById(
@RequestHeader(value = "Authorization", required = false) String authHeader,
@PathVariable String id
) {
String userId = extractUserIdFromHeader(authHeader);
if (userId == null) {
return unauthorizedResponse();
}
try {
Optional<MarketingAnalysisV3Document> analysisOpt = service.getAnalysisById(id);
if (analysisOpt.isEmpty()) {
return notFoundResponse("Анализ не найден");
}
MarketingAnalysisV3Document analysis = analysisOpt.get();
if (!analysis.getUserId().equals(userId)) {
return forbiddenResponse();
}
return ResponseEntity.ok(ApiResponse.success(analysis));
} catch (Exception e) {
log.error("Error fetching analysis V3: {}", id, e);
return internalErrorResponse(e);
}
}
@GetMapping("/my")
public ResponseEntity<?> getUserAnalyses(
@RequestHeader(value = "Authorization", required = false) String authHeader
) {
String userId = extractUserIdFromHeader(authHeader);
if (userId == null) {
return unauthorizedResponse();
}
try {
List<MarketingAnalysisV3Document> analyses = service.getAllByUser(userId);
return ResponseEntity.ok(ApiResponse.success(analyses));
} catch (Exception e) {
log.error("Error fetching user analyses V3", e);
return internalErrorResponse(e);
}
}
@ExceptionHandler(MethodArgumentNotValidException.class)
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));
}
private ResponseEntity<ApiResponse<Object>> unauthorizedResponse() {
ErrorResponse error = new ErrorResponse("UNAUTHORIZED", "Требуется авторизация");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ApiResponse.error("Не авторизован", error));
}
private ResponseEntity<ApiResponse<Object>> forbiddenResponse() {
ErrorResponse error = new ErrorResponse("FORBIDDEN", "Нет доступа к этому ресурсу");
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.error("Доступ запрещен", error));
}
private ResponseEntity<ApiResponse<Object>> notFoundResponse(String message) {
ErrorResponse error = new ErrorResponse("NOT_FOUND", message);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponse.error("Не найдено", error));
}
private ResponseEntity<ApiResponse<Object>> internalErrorResponse(Exception e) {
ErrorResponse error = new ErrorResponse("INTERNAL_SERVER_ERROR", e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ApiResponse.error("Ошибка сервера", error));
}
}
@@ -0,0 +1,122 @@
package kz.konturai.parser.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import kz.konturai.parser.enums.*;
import kz.konturai.parser.validator.ValidAnalysisType;
import kz.konturai.parser.validator.ValidDetailLevel;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class MarketingAnalysisV3Request {
// --- CORE IDENTIFIERS (Re-added from V1 for Search Logic) ---
@NotBlank(message = "Business Niche is required (e.g., 'Кофейня', 'Салон красоты')")
@Size(min = 2, max = 100)
private String businessNiche;
@NotBlank(message = "Product/Brand Name is required (e.g., 'Starbucks', 'MyBrand')")
@Size(min = 2, max = 100)
private String productName;
@NotBlank(message = "Goal is required (e.g., 'Увеличить продажи на 20%')")
@Size(min = 5, max = 300)
private String goal;
// --- BLOCK 1: Business Info ---
@NotNull(message = "Question 1 (Business Stage) is required")
private BusinessStage businessStage;
@NotNull(message = "Question 2 (Client Target) is required")
private ClientTarget clientTarget;
@NotNull(message = "Question 3 (Offer Type) is required")
private OfferType offerType;
@NotNull(message = "Question 4 (Average Check) is required")
private AverageCheck averageCheck;
@NotEmpty(message = "Question 5 (Customer Behavior) must have 1-2 options")
@Size(max = 2, message = "Select up to 2 options for Customer Behavior")
private List<CustomerBehavior> customerBehaviors;
// --- BLOCK 2: Geography ---
@NotNull(message = "Question 6 (Geo Scope) is required")
private GeoScope geoScope;
private String mainCity;
@Size(max = 5, message = "You can specify up to 5 presence cities")
private List<String> presenceCities;
@NotEmpty(message = "Question 7 (Target Cities) is required")
@Size(max = 5, message = "Select up to 5 cities for promotion")
private List<String> promotionCities;
// --- BLOCK 3: Product & Content ---
@NotBlank(message = "Question 8 (Product Description) is required")
@Size(min = 10, max = 1000, message = "Description should be detailed")
private String productDescription;
@NotNull(message = "Question 9 (Purchase Frequency) is required")
private PurchaseFrequency purchaseFrequency;
@NotNull(message = "Question 10 (Visual Factor) is required")
private VisualFactor visualFactor;
// --- BLOCK 4: Decision Making ---
@NotEmpty(message = "Question 11 (Priorities) must have 1-2 options")
@Size(max = 2, message = "Select up to 2 options for Client Priorities")
private List<DecisionPriority> decisionPriorities;
@NotEmpty(message = "Question 12 (Discovery Method) must have 1-2 options")
@Size(max = 2, message = "Select up to 2 options for Discovery Method")
private List<DiscoveryMethod> discoveryMethods;
@NotNull(message = "Question 13 (Price Feedback) is required")
private PriceFeedback priceFeedback;
// --- BLOCK 5: Current Situation ---
@NotNull(message = "Question 14 (SMM Status) is required")
private SmmStatus smmStatus;
@NotNull(message = "Question 15 (Lead Volume) is required")
private LeadVolume leadVolume;
@NotNull(message = "Question 16 (Response Handling) is required")
private ResponseHandler responseHandler;
private List<BusinessConstraint> constraints;
// --- BLOCK 6: Digital Assets (For v4.0 User Positioning) ---
@Size(max = 5, message = "Provide up to 5 links to your current social media/website")
private List<String> userSocialLinks;
@Size(max = 5, message = "Provide up to 5 known competitors (optional)")
private List<String> knownCompetitorLinks;
// --- TECHNICAL FIELDS ---
@NotBlank(message = "Detail Level is required")
@ValidDetailLevel
private String detailLevel;
@NotEmpty(message = "Analysis Type is required")
@ValidAnalysisType
private List<String> analysisType;
}
@@ -0,0 +1,162 @@
package kz.konturai.parser.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
public class MarketingAnalysisV3Result {
@JsonProperty("0_executive_summary")
private ExecutiveSummary executiveSummary;
@JsonProperty("1_market_landscape")
private MarketLandscape marketLandscape;
@JsonProperty("2_geo_structure")
private GeoStructure geoStructure;
@JsonProperty("3_competitor_map")
private List<CompetitorProfile> competitorMap;
@JsonProperty("4_content_profile")
private ContentProfile contentProfile;
@JsonProperty("5_competition_intensity")
private CompetitionIntensity competitionIntensity;
@JsonProperty("6_reputation_analysis")
private ReputationAnalysis reputationAnalysis;
@JsonProperty("7_behavioral_pattern")
private BehavioralPattern behavioralPattern;
@JsonProperty("8_search_demand")
private SearchDemand searchDemand;
@JsonProperty("9_user_positioning")
private UserPositioning userPositioning;
@JsonProperty("10_structured_conclusions")
private List<String> structuredConclusions;
@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 String averageNicheEr;
private double averageRating;
private String demandTrend;
private List<String> keyFigures;
}
@Data
public static class MarketLandscape {
private Map<String, Integer> activePlayersByPlatform;
private Map<String, Integer> cityDistribution;
private List<TimeSeriesPoint> demandDynamics;
private double nicheReputationLevel;
}
@Data
public static class GeoStructure {
private List<CityMetrics> cityComparison;
private double densityIndex;
}
@Data
public static class CityMetrics {
private String city;
private int activePlayers;
private double avgEr;
private double avgRating;
private int avgPostsPerMonth;
}
@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;
}
@Data
public static class ContentProfile {
private double demoContentPercent;
private double expertContentPercent;
private double salesContentPercent;
private double reviewsContentPercent;
private double engagementContentPercent;
private double videoShare;
private String avgTextLength;
private String ctaFrequency;
}
@Data
public static class CompetitionIntensity {
private int ciiIndex;
private String intensityLabel;
private List<String> contributingFactors;
}
@Data
public static class ReputationAnalysis {
private double avgNicheRating;
private int medianReviews;
private Map<String, Double> starDistribution;
private double highTrustBusinessShare;
private String avgOwnerResponseSpeed;
}
@Data
public static class BehavioralPattern {
private String promoFrequency;
private String bookingFrequency;
private String dmRequestFrequency;
private String priceVisibility;
private String avgCycleDuration;
private List<String> commonCta;
}
@Data
public static class SearchDemand {
private String avgFrequency;
private List<TimeSeriesPoint> seasonality;
private List<String> peakPeriods;
private List<String> relatedQueries;
}
@Data
public static class UserPositioning {
private RadarMetrics radarChart;
private String status;
private List<String> gaps;
}
@Data
public static class RadarMetrics {
private int activity;
private int engagement;
private int video;
private int reputation;
private int frequency;
}
@Data
public static class TimeSeriesPoint {
private String period;
private double value;
}
}
@@ -0,0 +1,14 @@
package kz.konturai.parser.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum AverageCheck {
LOW("Низкий"),
MEDIUM("Средний"),
HIGH("Высокий");
private final String description;
}
@@ -0,0 +1,16 @@
package kz.konturai.parser.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum BusinessConstraint {
NONE("Нет ограничений"),
SEASONALITY("Сезонность"),
SMALL_TEAM("Небольшая команда"),
LIMITED_BUDGET("Ограниченный бюджет"),
LONG_DEAL_CYCLE("Долгий цикл сделки");
private final String description;
}
@@ -0,0 +1,15 @@
package kz.konturai.parser.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum BusinessStage {
JUST_STARTING("Только запускаемся"),
LESS_THAN_ONE_YEAR("Работаем до 1 года"),
ONE_TO_THREE_YEARS("Работаем 13 года"),
MORE_THAN_THREE_YEARS("Работаем более 3 лет");
private final String description;
}
@@ -0,0 +1,14 @@
package kz.konturai.parser.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum ClientTarget {
PRIVATE_CLIENTS("Частные клиенты"),
BUSINESS("Бизнес"),
MIXED("И частные клиенты, и бизнес");
private final String description;
}
@@ -0,0 +1,16 @@
package kz.konturai.parser.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum CustomerBehavior {
BUY_QUICKLY("Покупает быстро"),
COMPARE_OPTIONS("Сравнивает варианты"),
REQUEST_PROPOSAL("Просит расчёт / КП"),
CHECK_REVIEWS("Просит кейсы / отзывы"),
NEED_CONSULTATION("Приходит на консультацию");
private final String description;
}
@@ -0,0 +1,16 @@
package kz.konturai.parser.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum DecisionPriority {
PRICE("Цена"),
QUALITY("Качество"),
SPEED("Скорость"),
TRUST("Надёжность / доверие"),
SERVICE("Сервис");
private final String description;
}
@@ -0,0 +1,16 @@
package kz.konturai.parser.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum DiscoveryMethod {
SEARCH_WEB("Ищут в интернете и сравнивают"),
RECOMMENDATIONS("Приходят по рекомендациям"),
PRICE_SELECTION("Выбирают по цене"),
REVIEWS("Выбирают по отзывам"),
CONVENIENCE("Выбирают по удобству");
private final String description;
}
@@ -0,0 +1,15 @@
package kz.konturai.parser.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum GeoScope {
SINGLE_CITY("Один город"),
MULTI_CITY("Несколько городов"),
FULL_COUNTRY("Вся страна"),
ONLINE("Онлайн");
private final String description;
}
@@ -0,0 +1,14 @@
package kz.konturai.parser.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum LeadVolume {
NONE("Нет"),
SOMETIMES("Иногда"),
REGULARLY("Регулярно");
private final String description;
}
@@ -0,0 +1,14 @@
package kz.konturai.parser.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum OfferType {
SERVICES("Услуги"),
GOODS("Товары"),
SUBSCRIPTION("Подписка / регулярное обслуживание");
private final String description;
}
@@ -0,0 +1,15 @@
package kz.konturai.parser.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum PriceFeedback {
LOWER_THAN_EXPECTED("Ниже, чем ожидали"),
AS_EXPECTED("Примерно как ожидали"),
HIGHER_THAN_EXPECTED("Выше, чем ожидали"),
NOT_DISCUSSED("Цену почти не обсуждают");
private final String description;
}
@@ -0,0 +1,14 @@
package kz.konturai.parser.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum PurchaseFrequency {
ONCE("Один раз"),
MULTIPLE_PER_YEAR("Несколько раз в год"),
REGULARLY("Регулярно / постоянно");
private final String description;
}
@@ -0,0 +1,14 @@
package kz.konturai.parser.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum ResponseHandler {
MYSELF("Я сам"),
EMPLOYEE("Сотрудник"),
DELAYED("С задержкой");
private final String description;
}
@@ -0,0 +1,15 @@
package kz.konturai.parser.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum SmmStatus {
NONE("Нет"),
DORMANT("Есть, но не ведём"),
IRREGULAR("Ведём нерегулярно"),
ACTIVE("Ведём активно");
private final String description;
}
@@ -0,0 +1,14 @@
package kz.konturai.parser.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum VisualFactor {
VISUALS_IMPORTANT("Да, фото и видео важны"),
PARTIALLY("Частично"),
EXPLANATION_IMPORTANT("Нет, важнее объяснение и доверие");
private final String description;
}
@@ -0,0 +1,36 @@
package kz.konturai.parser.model;
import kz.konturai.parser.dto.MarketingAnalysisV3Request;
import kz.konturai.parser.dto.MarketingAnalysisV3Result;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDateTime;
import java.util.Map;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Document(collection = "marketing_analysis_v3")
public class MarketingAnalysisV3Document {
@Id
private String id;
private String userId;
private String status;
private MarketingAnalysisV3Request requestData;
private MarketingAnalysisV3Result resultData;
private Map<String, Object> researchMetaData;
private String errorMessage;
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
}
@@ -0,0 +1,12 @@
package kz.konturai.parser.repository;
import kz.konturai.parser.model.MarketingAnalysisV3Document;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface MarketingAnalysisV3Repository extends MongoRepository<MarketingAnalysisV3Document, String> {
List<MarketingAnalysisV3Document> findAllByUserIdOrderByCreatedAtDesc(String userId);
}
@@ -0,0 +1,400 @@
package kz.konturai.parser.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import kz.konturai.parser.dto.*;
import kz.konturai.parser.model.MarketingAnalysisV3Document;
import kz.konturai.parser.repository.MarketingAnalysisV3Repository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
@Service
@RequiredArgsConstructor
@Slf4j
public class MarketingAnalysisV3Service {
private final MarketingAnalysisV3Repository repository;
private final SerperSearchService searchService;
private final OpenAIAnalyticsService aiService;
private final ObjectMapper objectMapper;
@Value("${openai.model.name.text:gpt-4o}")
private String highIntelligenceModel;
public String createAndStartAnalysis(MarketingAnalysisV3Request request, String 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);
processAnalysisAsync(doc.getId(), request);
return doc.getId();
}
@Async("reportGenerationExecutor")
public void processAnalysisAsync(String docId, MarketingAnalysisV3Request request) {
try {
updateStatus(docId, "PROCESSING");
Map<String, Object> researchPack = executeDeepResearch(request);
saveResearchMetaData(docId, researchPack);
String systemPrompt = buildKazakhstanSystemPrompt();
String userPrompt = buildDataDrivenUserPrompt(request, researchPack);
String jsonResponse = generateAiResponseWithRetry(userPrompt, systemPrompt);
MarketingAnalysisV3Result result = parseAndValidateResult(jsonResponse);
completeAnalysis(docId, result);
} catch (Exception e) {
log.error("Analysis V3 Failed for ID {}: {}", docId, e.getMessage(), e);
failAnalysis(docId, e.getMessage());
}
}
private Map<String, Object> executeDeepResearch(MarketingAnalysisV3Request request) {
Map<String, Object> pack = new ConcurrentHashMap<>();
List<String> queries = new ArrayList<>();
String niche = request.getBusinessNiche() != null ? request.getBusinessNiche() : "";
String product = request.getProductName() != null ? request.getProductName() : "";
List<String> cities = request.getPromotionCities() != null && !request.getPromotionCities().isEmpty()
? request.getPromotionCities()
: (request.getPresenceCities() != null && !request.getPresenceCities().isEmpty()
? request.getPresenceCities()
: List.of("Казахстан"));
String geoContext = String.join(" ", cities);
String topic = (niche + " " + product).trim();
if (topic.isEmpty()) topic = 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 -> {
try {
pack.put(q, searchService.search(q));
} catch (Exception e) {
pack.put(q, Map.of("error", e.getMessage(), "status", "ERROR"));
}
});
pack.put("generatedAt", LocalDateTime.now().toString());
return pack;
}
private String generateAiResponseWithRetry(String userPrompt, String systemPrompt) {
int attempts = 0;
int maxAttempts = 3;
String lastError = "";
while (attempts < maxAttempts) {
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";
}
} catch (JsonProcessingException e) {
lastError = "JSON Parse Error: " + e.getMessage();
} catch (Exception e) {
lastError = "API Error: " + e.getMessage();
}
attempts++;
try { Thread.sleep(2500L * attempts); } catch (InterruptedException ignored) {}
}
throw new RuntimeException("Failed to generate valid JSON after " + maxAttempts + " attempts. Last error: " + lastError);
}
private MarketingAnalysisV3Result parseAndValidateResult(String json) throws Exception {
return objectMapper.readValue(json, MarketingAnalysisV3Result.class);
}
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;
}
private void updateStatus(String id, String status) {
repository.findById(id).ifPresent(doc -> {
doc.setStatus(status);
doc.setUpdatedAt(LocalDateTime.now());
repository.save(doc);
});
}
private void saveResearchMetaData(String id, Map<String, Object> researchPack) {
repository.findById(id).ifPresent(doc -> {
doc.setResearchMetaData(researchPack);
repository.save(doc);
});
}
private void completeAnalysis(String id, MarketingAnalysisV3Result result) {
repository.findById(id).ifPresent(doc -> {
doc.setResultData(result);
doc.setStatus("COMPLETED");
doc.setUpdatedAt(LocalDateTime.now());
repository.save(doc);
});
}
private void failAnalysis(String id, String error) {
repository.findById(id).ifPresent(doc -> {
doc.setStatus("FAILED");
doc.setErrorMessage(error);
doc.setUpdatedAt(LocalDateTime.now());
repository.save(doc);
});
}
public Optional<MarketingAnalysisV3Document> getAnalysisById(String id) {
return repository.findById(id);
}
public List<MarketingAnalysisV3Document> getAllByUser(String userId) {
return repository.findAllByUserIdOrderByCreatedAtDesc(userId);
}
private String buildKazakhstanSystemPrompt() {
return """
РОЛЬ: Ты — Chief Data Officer и Стратег Big 4 (PwC, BCG) по рынку Казахстана.
ЗАДАЧА: Сгенерировать глубокий, Data-Driven "Marketing Analysis v4.0" в строгом JSON формате.
КРИТИЧЕСКИЕ ПРАВИЛА (ANTI-HALLUCINATION PROTOCOL):
1. ТОЛЬКО РЕАЛЬНЫЕ ДАННЫЕ: Вся аналитика строится на переданном SEARCH EVIDENCE.
2. РЕАЛЬНЫЕ КОНКУРЕНТЫ: В блоке `3_competitor_map` ОБЯЗАНО быть от 3 до 5 реально существующих компаний, найденных в SEARCH EVIDENCE (например "Invictus", "Sulpak", "Korean House"). КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО писать "Конкурент 1", "Компания А", "Пример".
3. ОЦИФРОВКА: В Казахстане все измеряется в KZT. Приложение Kaspi.kz и 2GIS — основа рынка. Учитывай это в анализе.
4. СВЯЗЬ СО СТРАТЕГИЕЙ: Твой анализ — это фундамент для будущего SMM-Scoring Model (Entry/Authority/Trust/Conversion). Анализируй данные так, чтобы выявить доминирующий барьер аудитории.
5. НИКАКИХ NULL: Заполни абсолютно все поля JSON. Если точной цифры нет, примени метод Ферми и сделай экстраполяцию, характерную для рынка РК.
""";
}
private String buildDataDrivenUserPrompt(MarketingAnalysisV3Request request, Map<String, Object> researchPack) {
try {
String requestJson = objectMapper.writeValueAsString(request);
String evidenceJson = objectMapper.writeValueAsString(researchPack);
String schemaTemplate = getJsonStructureTemplate();
return """
СФОРМИРУЙ ОТЧЕТ "MARKETING ANALYSIS V4.0" ДЛЯ РЫНКА КАЗАХСТАНА.
ДАННЫЕ КЛИЕНТА (DTO):
%s
РАЗВЕДДАННЫЕ (SEARCH EVIDENCE):
%s
ИНСТРУКЦИИ ПО СЕКЦИЯМ (СТРОГО):
[0_executive_summary]
- activeCompetitors: Реальное количество найденных в поиске игроков.
- keyFigures: 5-7 мощных метрик. Обязательно укажи оценку объема рынка/спроса.
[1_market_landscape]
- demandDynamics: 12 объектов. Поле "period" СТРОГО на русском (Январь, Февраль...). "value" отражает сезонность.
[3_competitor_map]
- ВЫВЕДИ 3, 4 ИЛИ 5 РЕАЛЬНЫХ КОНКУРЕНТОВ.
- Вытащи их сильные/слабые стороны из отзывов в SEARCH EVIDENCE.
- Рассчитай реалистичный ER (от 0.01 до 0.08).
[4_content_profile]
- Сумма всех ContentPercent должна быть ровно 100.0. Распредели в зависимости от ниши (если визуал важен - демо/видео выше; если B2B - экспертность выше).
[5_competition_intensity]
- ciiIndex: 0-100. Оцени по плотности выдачи 2GIS и Google в EVIDENCE.
[7_behavioral_pattern]
- Отрази привычки КЗ: Kaspi Red, WhatsApp, чувствительность к скидкам.
[9_user_positioning]
- Если DTO smmStatus == "NONE", радар-чарт по нулям.
[10_structured_conclusions]
- Подведи итог: Какой фактор доминирует? (Быстрая покупка, долгий цикл, перегретый рынок, или запуск). Это нужно для будущей скоринг-модели.
ЭТАЛОННЫЙ JSON:
%s
""".formatted(requestJson, evidenceJson, schemaTemplate);
} catch (Exception e) {
throw new RuntimeException("Error building prompt", e);
}
}
private String getJsonStructureTemplate() {
return """
{
"0_executive_summary": {
"businessStage": "string",
"geography": "string",
"activeCompetitors": 0,
"competitionLevel": "string",
"averageNicheEr": "string",
"averageRating": 0.0,
"demandTrend": "string",
"keyFigures": ["string", "string", "string"]
},
"1_market_landscape": {
"activePlayersByPlatform": {"Instagram": 0, "TikTok": 0, "GoogleMaps": 0},
"cityDistribution": {"Almaty": 0},
"demandDynamics": [
{"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}
],
"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"]
},
{
"name": "РЕАЛЬНОЕ НАЗВАНИЕ БРЕНДА 2",
"platform": "string",
"followers": 0,
"postsPerMonth": 0,
"er": 0.0,
"rating": 0.0,
"reviews": 0,
"strengths": ["string"],
"weaknesses": ["string"]
},
{
"name": "РЕАЛЬНОЕ НАЗВАНИЕ БРЕНДА 3",
"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,
"avgTextLength": "string",
"ctaFrequency": "string"
},
"5_competition_intensity": {
"ciiIndex": 0,
"intensityLabel": "string",
"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,
"avgOwnerResponseSpeed": "string"
},
"7_behavioral_pattern": {
"promoFrequency": "string",
"bookingFrequency": "string",
"dmRequestFrequency": "string",
"priceVisibility": "string",
"avgCycleDuration": "string",
"commonCta": ["string"]
},
"8_search_demand": {
"avgFrequency": "string",
"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": {
"radarChart": {
"activity": 0,
"engagement": 0,
"video": 0,
"reputation": 0,
"frequency": 0
},
"status": "string",
"gaps": ["string"]
},
"10_structured_conclusions": ["string", "string", "string"],
"11_smm_strategy_rationale": "string"
}
""";
}
}