fix
This commit is contained in:
@@ -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("Работаем 1–3 года"),
|
||||
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"
|
||||
}
|
||||
""";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user