diff --git a/src/main/java/kz/konturai/parser/controller/MarketingController.java b/src/main/java/kz/konturai/parser/controller/MarketingController.java index 071e336..b3df04d 100644 --- a/src/main/java/kz/konturai/parser/controller/MarketingController.java +++ b/src/main/java/kz/konturai/parser/controller/MarketingController.java @@ -94,11 +94,13 @@ public class MarketingController { // errors. boolean legacyGenerateDisabled = true; if (legacyGenerateDisabled) { - ErrorResponse disabledError = new ErrorResponse( - "ENDPOINT_DISABLED", + MarketingAnalysisResponse disabledResponse = new MarketingAnalysisResponse( + null, + "disabled", + null, "Обычный generate временно отключен. Используйте V2: POST /api/marketing/analysis/start/v2"); - return ResponseEntity.status(HttpStatus.GONE) - .body(ApiResponse.error("Обычный generate временно отключен", disabledError)); + return ResponseEntity.ok(ApiResponse.success("Обычный generate временно отключен", + disabledResponse)); } // Create unified analysis with all analysis types diff --git a/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java b/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java index b3256dd..f6004af 100644 --- a/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java +++ b/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java @@ -21,6 +21,7 @@ import org.springframework.stereotype.Service; import com.fasterxml.jackson.core.JsonParseException; import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.time.LocalDateTime; @@ -649,7 +650,10 @@ public class MarketingAnalysisService { Map researchPack = buildResearchPack(request); // 2) Build strict schema instructions + provide an example JSON structure - String exampleJson = objectMapper.writeValueAsString(buildAnalysisV2ExampleForPrompt(request)); + List competitorCompanies = resolveCompetitorCompaniesForBenchmarks(request, researchPack, 5); + researchPack.put("competitor_companies", competitorCompanies); + String exampleJson = objectMapper.writeValueAsString( + buildAnalysisV2ExampleForPrompt(request, competitorCompanies)); String systemPrompt = "" + "Ты — партнер Big Four (управленческий консалтинг) и senior marketing analyst.\n" @@ -664,7 +668,7 @@ public class MarketingAnalysisService { List ageRanges = (List) targetAudienceData.get("ageRanges"); if (ageRanges != null && !ageRanges.isEmpty()) { ageRangesInstruction = "\n" - + "7) КРИТИЧЕСКИ ВАЖНО для target_audience.age_structure: Используй ТОЛЬКО те возрастные группы, которые указаны в запросе (request.targetAudience.ageRanges). " + + "8) КРИТИЧЕСКИ ВАЖНО для target_audience.age_structure: Используй ТОЛЬКО те возрастные группы, которые указаны в запросе (request.targetAudience.ageRanges). " + "Указанные возрастные группы: " + String.join(", ", ageRanges) + ". " + "НЕ добавляй возрастные группы, которых нет в запросе. " + "Если в запросе указаны только \"25-34\" и \"35-44\", то в age_structure должны быть ТОЛЬКО эти две группы.\n"; @@ -685,7 +689,10 @@ public class MarketingAnalysisService { + " - funnel_metrics.leads/bookings/payments => integer\n" + " - market_overview.geography => object: ключ=город, значение=целое число (индекс интереса, 0..100)\n" + "5) Пиши на русском языке.\n" - + "6) НИКОГДА не копируй значения из примера. В примере намеренно стоят плейсхолдеры, " + + "6) competitive_environment.digital_visibility_benchmarks и competitive_environment.competitor_strengths: " + + "ключи должны быть названиями РЕАЛЬНЫХ компаний/брендов (используй evidence.competitor_companies и evidence.followups.competitors). " + + "Запрещены ключи-заглушки: competitor_a/competitor_b/competitor_c/your_business/lead/payment/others.\n" + + "7) НИКОГДА не копируй значения из примера. В примере намеренно стоят плейсхолдеры, " + "которые начинаются с 'EXAMPLE__' и числа -9999/-9999.0 — в твоем ответе их быть НЕ должно." + ageRangesInstruction + "\nНиже пример правильной структуры (пример только для формы/полей; все значения должны быть заменены на значения из evidence):\n" @@ -742,7 +749,9 @@ public class MarketingAnalysisService { continue; } - return objectMapper.readValue(cleaned, MarketingAnalysisResponseV2.class); + MarketingAnalysisResponseV2 parsed = objectMapper.readValue(cleaned, MarketingAnalysisResponseV2.class); + replacePlaceholderCompanyKeysIfNeeded(parsed, competitorCompanies); + return parsed; } throw new IllegalStateException( @@ -762,7 +771,273 @@ public class MarketingAnalysisService { return true; } // Numeric placeholders from buildAnalysisV2ExampleForPrompt() - return json.contains("-9999"); + if (json.contains("-9999")) { + return true; + } + + // Check placeholder competitor keys specifically inside competitive_environment + // maps. + // This avoids false positives for normal Russian words like "лид/оплата" + // appearing in conclusions. + try { + JsonNode root = objectMapper.readTree(json); + JsonNode ce = root.at("/sections/competitive_environment"); + if (ce == null || ce.isMissingNode()) { + return false; + } + + JsonNode dvb = ce.get("digital_visibility_benchmarks"); + if (dvb != null && dvb.isObject()) { + Iterator it = dvb.fieldNames(); + while (it.hasNext()) { + if (isPlaceholderCompetitorKey(it.next())) { + return true; + } + } + } + + JsonNode strengths = ce.get("competitor_strengths"); + if (strengths != null && strengths.isObject()) { + Iterator it = strengths.fieldNames(); + while (it.hasNext()) { + if (isPlaceholderCompetitorKey(it.next())) { + return true; + } + } + } + return false; + } catch (Exception e) { + // If we can't parse JSON here, treat as unusable and retry. + return true; + } + } + + private boolean isPlaceholderCompetitorKey(String key) { + if (key == null) { + return false; + } + String k = key.trim().toLowerCase(Locale.ROOT); + if (k.isEmpty()) { + return false; + } + return k.startsWith("competitor_") + || "your_business".equals(k) + || "lead".equals(k) + || "payment".equals(k) + || "others".equals(k); + } + + private List resolveCompetitorCompaniesForBenchmarks(MarketingAnalysisRequest request, + Map researchPack, + int limit) { + int max = Math.max(1, Math.min(limit, 10)); + LinkedHashSet names = new LinkedHashSet<>(); + + SerperSearchResult fromPack = null; + if (researchPack != null) { + Object followupsObj = researchPack.get("followups"); + if (followupsObj instanceof Map followups) { + Object compObj = followups.get("competitors"); + if (compObj instanceof SerperSearchResult sr) { + fromPack = sr; + } + } + } + names.addAll(extractCompanyNamesFromSerper(fromPack, max)); + + // If competitors query returned mostly directories/articles, do a second query + // to get more brand-like results. + if (names.size() < max) { + String topic = deriveTopic(request); + String regionStr = deriveRegionStr(request); + String q = topic + " " + regionStr + " официальный сайт"; + try { + SerperSearchResult extra = serperSearchService.search(q); + names.addAll(extractCompanyNamesFromSerper(extra, max - names.size())); + } catch (Exception ignored) { + } + } + + List out = new ArrayList<>(names); + return out.size() > max ? out.subList(0, max) : out; + } + + private String deriveTopic(MarketingAnalysisRequest request) { + String product = request != null ? request.getProduct() : null; + String niche = request != null ? request.getBusinessNiche() : null; + // For competitor/brand lookup, product is usually more specific than niche. + String topic = (product != null && !product.isBlank()) ? product : (niche != null ? niche : null); + return (topic == null || topic.isBlank()) ? "ниша" : topic.trim(); + } + + private String deriveRegionStr(MarketingAnalysisRequest request) { + List regions = request != null ? request.getRegion() : null; + return (regions != null && !regions.isEmpty()) ? String.join(", ", regions) : "Казахстан"; + } + + private List extractCompanyNamesFromSerper(SerperSearchResult result, int limit) { + int max = Math.max(0, Math.min(limit, 20)); + if (max == 0 || result == null || result.items() == null) { + return List.of(); + } + + LinkedHashSet out = new LinkedHashSet<>(); + for (SerperSearchItem item : result.items()) { + if (item == null) { + continue; + } + + String name = extractCompanyNameCandidate(item.title()); + if (isGoodCompanyNameCandidate(name)) { + out.add(name); + } else { + String fromHost = extractCompanyNameFromHost(item.sourceHost()); + if (isGoodCompanyNameCandidate(fromHost)) { + out.add(fromHost); + } + } + + if (out.size() >= max) { + break; + } + } + return new ArrayList<>(out); + } + + private String extractCompanyNameFromHost(String host) { + if (host == null) { + return null; + } + String h = host.trim().toLowerCase(Locale.ROOT); + if (h.isEmpty()) { + return null; + } + h = h.startsWith("www.") ? h.substring(4) : h; + + // Ignore obvious aggregators/social networks. + String[] blocked = new String[] { + "google.", "yandex.", "2gis.", "instagram.", "facebook.", "vk.", "tiktok.", "youtube.", "wikipedia." + }; + for (String b : blocked) { + if (h.startsWith(b) || h.contains("." + b) || h.equals(b)) { + return null; + } + } + + String[] parts = h.split("\\."); + if (parts.length < 2) { + return null; + } + String sld = parts[0]; + if (sld.isBlank() || sld.length() > 40) { + return null; + } + + // Basic "humanization" of domain label. + String cleaned = sld.replaceAll("[^a-z0-9\\-]+", "").replace('-', ' ').trim(); + if (cleaned.isEmpty()) { + return null; + } + // Title-case first character + return cleaned.substring(0, 1).toUpperCase(Locale.ROOT) + cleaned.substring(1); + } + + private String extractCompanyNameCandidate(String title) { + if (title == null) { + return null; + } + String t = title.replace('\u00A0', ' ').trim(); + if (t.isEmpty()) { + return null; + } + + // Keep the left-most "brand-like" chunk: "Brand — ...", "Brand | ...", "Brand - + // ..." + String[] seps = new String[] { " — ", " - ", " | ", " – ", ":", "|", "—", "–" }; + for (String sep : seps) { + int idx = t.indexOf(sep); + if (idx > 1) { + t = t.substring(0, idx).trim(); + break; + } + } + + t = t.replaceAll("[\"“”«»]+", "").trim(); + if (t.isEmpty()) { + return null; + } + + // Trim common location tails: "... Алматы", "... Астана", "... Казахстан" + t = t.replaceAll("\\s+(Алматы|Астана|Казахстан)\\s*$", "").trim(); + return t.isEmpty() ? null : t; + } + + private boolean isGoodCompanyNameCandidate(String s) { + if (s == null) { + return false; + } + String t = s.trim(); + if (t.length() < 2 || t.length() > 60) { + return false; + } + String lower = t.toLowerCase(Locale.ROOT); + + // Exclude obvious non-brands (directories/articles) + String[] bad = new String[] { + "топ", "рейтинг", "обзор", "лучшие", "лучших", "каталог", "список", "цены", "стоимость", + "отзывы", "адрес", "телефон", "контакты", "как выбрать", "вакансии", "купить", "доставка" + }; + for (String b : bad) { + if (lower.contains(b)) { + return false; + } + } + + // Too many words is usually an article title, not a brand + if (t.split("\\s+").length > 5) { + return false; + } + return true; + } + + private void replacePlaceholderCompanyKeysIfNeeded(MarketingAnalysisResponseV2 parsed, + List competitorCompanies) { + if (parsed == null || competitorCompanies == null || competitorCompanies.isEmpty()) { + return; + } + if (parsed.getSections() == null || parsed.getSections().getCompetitiveEnvironment() == null) { + return; + } + + MarketingAnalysisResponseV2.CompetitiveEnvironment ce = parsed.getSections().getCompetitiveEnvironment(); + + Map dvb = ce.getDigitalVisibilityBenchmarks(); + if (dvb != null && !dvb.isEmpty()) { + boolean hasPlaceholders = dvb.keySet().stream().anyMatch(this::isPlaceholderCompetitorKey); + if (hasPlaceholders) { + List values = new ArrayList<>(dvb.values()); + LinkedHashMap rewritten = new LinkedHashMap<>(); + int n = Math.min(values.size(), competitorCompanies.size()); + for (int i = 0; i < n; i++) { + rewritten.put(competitorCompanies.get(i), values.get(i)); + } + ce.setDigitalVisibilityBenchmarks(rewritten); + } + } + + Map> strengths = ce.getCompetitorStrengths(); + if (strengths != null && !strengths.isEmpty()) { + boolean hasPlaceholders = strengths.keySet().stream().anyMatch(this::isPlaceholderCompetitorKey); + if (hasPlaceholders) { + List> values = new ArrayList<>(strengths.values()); + LinkedHashMap> rewritten = new LinkedHashMap<>(); + int n = Math.min(values.size(), competitorCompanies.size()); + for (int i = 0; i < n; i++) { + rewritten.put(competitorCompanies.get(i), values.get(i)); + } + ce.setCompetitorStrengths(rewritten); + } + } } /** @@ -855,7 +1130,8 @@ public class MarketingAnalysisService { * @param request Запрос на анализ для динамического формирования примера * age_structure */ - private MarketingAnalysisResponseV2 buildAnalysisV2ExampleForPrompt(MarketingAnalysisRequest request) { + private MarketingAnalysisResponseV2 buildAnalysisV2ExampleForPrompt(MarketingAnalysisRequest request, + List competitorCompanies) { MarketingAnalysisResponseV2 response = new MarketingAnalysisResponseV2(); response.setReportTitle("EXAMPLE__REPORT_TITLE"); @@ -946,17 +1222,19 @@ public class MarketingAnalysisService { ceMetrics.setBenchmarkAstana("EXAMPLE__BENCHMARK_ASTANA"); competitiveEnvironment.setMetrics(ceMetrics); Map digitalVisibilityBenchmarks = new LinkedHashMap<>(); - digitalVisibilityBenchmarks.put("competitor_a", -9999); - digitalVisibilityBenchmarks.put("your_business", -9999); - digitalVisibilityBenchmarks.put("lead", -9999); - digitalVisibilityBenchmarks.put("payment", -9999); - digitalVisibilityBenchmarks.put("others", -9999); + List companies = (competitorCompanies != null) ? competitorCompanies : List.of(); + if (companies.isEmpty()) { + companies = List.of("Компания 1", "Компания 2", "Компания 3", "Компания 4", "Компания 5"); + } + for (int i = 0; i < Math.min(companies.size(), 5); i++) { + digitalVisibilityBenchmarks.put(companies.get(i), -9999); + } competitiveEnvironment.setDigitalVisibilityBenchmarks(digitalVisibilityBenchmarks); Map> competitorStrengths = new LinkedHashMap<>(); - competitorStrengths.put("your_business", List.of("EXAMPLE__STRENGTH_1", "EXAMPLE__STRENGTH_2")); - competitorStrengths.put("competitor_a", List.of("EXAMPLE__COMP_A_STRENGTH_1", "EXAMPLE__COMP_A_STRENGTH_2")); - competitorStrengths.put("competitor_b", List.of("EXAMPLE__COMP_B_STRENGTH_1", "EXAMPLE__COMP_B_STRENGTH_2")); - competitorStrengths.put("competitor_c", List.of("EXAMPLE__COMP_C_STRENGTH_1", "EXAMPLE__COMP_C_STRENGTH_2")); + for (int i = 0; i < Math.min(companies.size(), 5); i++) { + competitorStrengths.put(companies.get(i), + List.of("EXAMPLE__COMPETITOR_STRENGTH_1", "EXAMPLE__COMPETITOR_STRENGTH_2")); + } competitiveEnvironment.setCompetitorStrengths(competitorStrengths); competitiveEnvironment.setConclusion("EXAMPLE__COMPETITIVE_ENVIRONMENT_CONCLUSION"); sections.setCompetitiveEnvironment(competitiveEnvironment);