diff --git a/src/main/java/kz/konturai/parser/controller/MarketingAnalysisV3Controller.java b/src/main/java/kz/konturai/parser/controller/MarketingAnalysisV3Controller.java new file mode 100644 index 0000000..06cb24c --- /dev/null +++ b/src/main/java/kz/konturai/parser/controller/MarketingAnalysisV3Controller.java @@ -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 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 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 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> handleValidationException(MethodArgumentNotValidException ex) { + Map details = new HashMap<>(); + ex.getBindingResult().getFieldErrors().forEach(error -> + details.put(error.getField(), error.getDefaultMessage()) + ); + + ErrorResponse error = new ErrorResponse( + "VALIDATION_ERROR", + "Ошибка валидации входных данных", + details + ); + + return ResponseEntity.badRequest().body(ApiResponse.error("Ошибка валидации", error)); + } + + private ResponseEntity> unauthorizedResponse() { + ErrorResponse error = new ErrorResponse("UNAUTHORIZED", "Требуется авторизация"); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ApiResponse.error("Не авторизован", error)); + } + + private ResponseEntity> forbiddenResponse() { + ErrorResponse error = new ErrorResponse("FORBIDDEN", "Нет доступа к этому ресурсу"); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.error("Доступ запрещен", error)); + } + + private ResponseEntity> notFoundResponse(String message) { + ErrorResponse error = new ErrorResponse("NOT_FOUND", message); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponse.error("Не найдено", error)); + } + + private ResponseEntity> internalErrorResponse(Exception e) { + ErrorResponse error = new ErrorResponse("INTERNAL_SERVER_ERROR", e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ApiResponse.error("Ошибка сервера", error)); + } +} \ No newline at end of file diff --git a/src/main/java/kz/konturai/parser/dto/MarketingAnalysisV3Request.java b/src/main/java/kz/konturai/parser/dto/MarketingAnalysisV3Request.java new file mode 100644 index 0000000..cfcf7fe --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/MarketingAnalysisV3Request.java @@ -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 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 presenceCities; + + @NotEmpty(message = "Question 7 (Target Cities) is required") + @Size(max = 5, message = "Select up to 5 cities for promotion") + private List 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 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 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 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 userSocialLinks; + + @Size(max = 5, message = "Provide up to 5 known competitors (optional)") + private List knownCompetitorLinks; + + // --- TECHNICAL FIELDS --- + + @NotBlank(message = "Detail Level is required") + @ValidDetailLevel + private String detailLevel; + + @NotEmpty(message = "Analysis Type is required") + @ValidAnalysisType + private List analysisType; +} \ No newline at end of file diff --git a/src/main/java/kz/konturai/parser/dto/MarketingAnalysisV3Result.java b/src/main/java/kz/konturai/parser/dto/MarketingAnalysisV3Result.java new file mode 100644 index 0000000..5ca6266 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/MarketingAnalysisV3Result.java @@ -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 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 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 keyFigures; + } + + @Data + public static class MarketLandscape { + private Map activePlayersByPlatform; + private Map cityDistribution; + private List demandDynamics; + private double nicheReputationLevel; + } + + @Data + public static class GeoStructure { + private List 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 strengths; + private List 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 contributingFactors; + } + + @Data + public static class ReputationAnalysis { + private double avgNicheRating; + private int medianReviews; + private Map 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 commonCta; + } + + @Data + public static class SearchDemand { + private String avgFrequency; + private List seasonality; + private List peakPeriods; + private List relatedQueries; + } + + @Data + public static class UserPositioning { + private RadarMetrics radarChart; + private String status; + private List 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; + } +} \ No newline at end of file diff --git a/src/main/java/kz/konturai/parser/enums/AverageCheck.java b/src/main/java/kz/konturai/parser/enums/AverageCheck.java new file mode 100644 index 0000000..d439ece --- /dev/null +++ b/src/main/java/kz/konturai/parser/enums/AverageCheck.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/kz/konturai/parser/enums/BusinessConstraint.java b/src/main/java/kz/konturai/parser/enums/BusinessConstraint.java new file mode 100644 index 0000000..e58d5c6 --- /dev/null +++ b/src/main/java/kz/konturai/parser/enums/BusinessConstraint.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/kz/konturai/parser/enums/BusinessStage.java b/src/main/java/kz/konturai/parser/enums/BusinessStage.java new file mode 100644 index 0000000..e435202 --- /dev/null +++ b/src/main/java/kz/konturai/parser/enums/BusinessStage.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/kz/konturai/parser/enums/ClientTarget.java b/src/main/java/kz/konturai/parser/enums/ClientTarget.java new file mode 100644 index 0000000..7405492 --- /dev/null +++ b/src/main/java/kz/konturai/parser/enums/ClientTarget.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/kz/konturai/parser/enums/CustomerBehavior.java b/src/main/java/kz/konturai/parser/enums/CustomerBehavior.java new file mode 100644 index 0000000..e8d9fa9 --- /dev/null +++ b/src/main/java/kz/konturai/parser/enums/CustomerBehavior.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/kz/konturai/parser/enums/DecisionPriority.java b/src/main/java/kz/konturai/parser/enums/DecisionPriority.java new file mode 100644 index 0000000..1e0b976 --- /dev/null +++ b/src/main/java/kz/konturai/parser/enums/DecisionPriority.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/kz/konturai/parser/enums/DiscoveryMethod.java b/src/main/java/kz/konturai/parser/enums/DiscoveryMethod.java new file mode 100644 index 0000000..e33b074 --- /dev/null +++ b/src/main/java/kz/konturai/parser/enums/DiscoveryMethod.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/kz/konturai/parser/enums/GeoScope.java b/src/main/java/kz/konturai/parser/enums/GeoScope.java new file mode 100644 index 0000000..97f4fdc --- /dev/null +++ b/src/main/java/kz/konturai/parser/enums/GeoScope.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/kz/konturai/parser/enums/LeadVolume.java b/src/main/java/kz/konturai/parser/enums/LeadVolume.java new file mode 100644 index 0000000..8234007 --- /dev/null +++ b/src/main/java/kz/konturai/parser/enums/LeadVolume.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/kz/konturai/parser/enums/OfferType.java b/src/main/java/kz/konturai/parser/enums/OfferType.java new file mode 100644 index 0000000..a4375fb --- /dev/null +++ b/src/main/java/kz/konturai/parser/enums/OfferType.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/kz/konturai/parser/enums/PriceFeedback.java b/src/main/java/kz/konturai/parser/enums/PriceFeedback.java new file mode 100644 index 0000000..e9e3f64 --- /dev/null +++ b/src/main/java/kz/konturai/parser/enums/PriceFeedback.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/kz/konturai/parser/enums/PurchaseFrequency.java b/src/main/java/kz/konturai/parser/enums/PurchaseFrequency.java new file mode 100644 index 0000000..e12e569 --- /dev/null +++ b/src/main/java/kz/konturai/parser/enums/PurchaseFrequency.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/kz/konturai/parser/enums/ResponseHandler.java b/src/main/java/kz/konturai/parser/enums/ResponseHandler.java new file mode 100644 index 0000000..59b9c92 --- /dev/null +++ b/src/main/java/kz/konturai/parser/enums/ResponseHandler.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/kz/konturai/parser/enums/SmmStatus.java b/src/main/java/kz/konturai/parser/enums/SmmStatus.java new file mode 100644 index 0000000..bbc0288 --- /dev/null +++ b/src/main/java/kz/konturai/parser/enums/SmmStatus.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/kz/konturai/parser/enums/VisualFactor.java b/src/main/java/kz/konturai/parser/enums/VisualFactor.java new file mode 100644 index 0000000..27431b4 --- /dev/null +++ b/src/main/java/kz/konturai/parser/enums/VisualFactor.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/kz/konturai/parser/model/MarketingAnalysisV3Document.java b/src/main/java/kz/konturai/parser/model/MarketingAnalysisV3Document.java new file mode 100644 index 0000000..e175f7c --- /dev/null +++ b/src/main/java/kz/konturai/parser/model/MarketingAnalysisV3Document.java @@ -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 researchMetaData; + private String errorMessage; + + @CreatedDate + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/src/main/java/kz/konturai/parser/repository/MarketingAnalysisV3Repository.java b/src/main/java/kz/konturai/parser/repository/MarketingAnalysisV3Repository.java new file mode 100644 index 0000000..72f9e93 --- /dev/null +++ b/src/main/java/kz/konturai/parser/repository/MarketingAnalysisV3Repository.java @@ -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 { + List findAllByUserIdOrderByCreatedAtDesc(String userId); +} \ No newline at end of file diff --git a/src/main/java/kz/konturai/parser/service/MarketingAnalysisV3Service.java b/src/main/java/kz/konturai/parser/service/MarketingAnalysisV3Service.java new file mode 100644 index 0000000..d872f68 --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/MarketingAnalysisV3Service.java @@ -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 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 executeDeepResearch(MarketingAnalysisV3Request request) { + Map pack = new ConcurrentHashMap<>(); + List queries = new ArrayList<>(); + + String niche = request.getBusinessNiche() != null ? request.getBusinessNiche() : ""; + String product = request.getProductName() != null ? request.getProductName() : ""; + + List 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 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 getAnalysisById(String id) { + return repository.findById(id); + } + + public List 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 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" + } + """; + } +} \ No newline at end of file