From e5aa373c61f4508311aa576c4837c00540de4d11 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 20 Dec 2025 22:46:38 +0500 Subject: [PATCH] . --- .../service/MarketingAnalysisService.java | 175 ++++++++++++++++-- ...tingAnalysisServiceChartPlacementTest.java | 42 +++++ 2 files changed, 202 insertions(+), 15 deletions(-) diff --git a/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java b/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java index b843432..3382138 100644 --- a/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java +++ b/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java @@ -32,6 +32,10 @@ import java.util.concurrent.atomic.AtomicInteger; public class MarketingAnalysisService { private static final String AI_MANDATORY_JSON_USAGE_WARNING = "ВНИМАНИЕ: Если в предоставленном JSON есть данные, ты ОБЯЗАН использовать их и вставить соответствующий тег [[CHART_...]]. " + "Никогда не пиши, что данных нет, если JSON не пустой.\n\n"; + private static final String AI_ULTIMATE_JSON_PRESENCE_INSTRUCTION = "" + + "ИНСТРУКЦИЯ (ОБЯЗАТЕЛЬНО): Данные в JSON 100% присутствуют. Тебе ЗАПРЕЩЕНО писать, что данных нет/данные отсутствуют/нет информации.\n" + + "Найди нужный объект в JSON, проанализируй каждую цифру/процент и вставь соответствующий тег [[CHART_...]] / [[TABLE_...]] рядом с тем абзацем, где ты обсуждаешь эти данные.\n" + + "Если какая-то бизнес-метрика не представлена явно, используй аналитическую экстраполяцию и обозначь это как \"Экстраполяция\" (без фраз про отсутствие данных).\n\n"; private static final String AI_PREMIUM_CONSULTING_STYLE_GUIDE = "" + "СТИЛЬ (ОБЯЗАТЕЛЬНО): пиши как консалтинг «Большой четверки»: структурно, уверенно, с причинно-следственной логикой.\n" + "Используй профессиональную лексику и леммы: «аналитическая экстраполяция», «дифференциация предложения», «конверсионный слой», «кастомизация оффера».\n" @@ -45,6 +49,12 @@ public class MarketingAnalysisService { } } + private void appendUltimateJsonPresenceInstruction(StringBuilder promptBuilder) { + if (promptBuilder != null) { + promptBuilder.append(AI_ULTIMATE_JSON_PRESENCE_INSTRUCTION); + } + } + private void appendPremiumConsultingStyleGuide(StringBuilder promptBuilder) { if (promptBuilder != null) { promptBuilder.append(AI_PREMIUM_CONSULTING_STYLE_GUIDE); @@ -1337,6 +1347,44 @@ public class MarketingAnalysisService { return cleaned; } + /** + * Жесткая санитизация: убирает запрещённые фразы про отсутствие данных и, при + * необходимости, хвостовые плейсхолдеры [[CHART_*]]/[[TABLE_*]]. + * + * Цель: в финальном отчёте не должно быть ни одной фразы "данные отсутствуют"/"нет данных" + * и не должно оставаться токенов [[CHART_...]]. + */ + private String hardSanitizeReportText(String text, boolean removePlaceholders) { + if (text == null || text.isBlank()) { + return text; + } + + String cleaned = text; + + // 1) Replace "no data" sentences with a neutral analytical extrapolation statement. + String[] noDataSentencePatterns = new String[] { + "(?i)\\bк\\s*сожалению\\b[^\\n\\.\\!\\?]*\\bданн[а-яё]*\\b[^\\n\\.\\!\\?]*(нет|не\\s*представлен[а-яё]*)[^\\n\\.\\!\\?]*[\\.\\!\\?]?", + "(?i)\\bданные\\s*(отсутствуют|не\\s*представлен[а-яё]*|не\\s*доступн[а-яё]*)\\b[^\\n\\.\\!\\?]*[\\.\\!\\?]?", + "(?i)\\bнет\\s*данных\\b[^\\n\\.\\!\\?]*[\\.\\!\\?]?", + "(?i)\\bнет\\s*информации\\b[^\\n\\.\\!\\?]*[\\.\\!\\?]?" + }; + for (String p : noDataSentencePatterns) { + cleaned = cleaned.replaceAll(p, "Экстраполяция: применяем консервативные допущения и методику расчета."); + } + + // 2) Remove lingering short phrases (in case model wrote it without punctuation) + cleaned = cleaned.replaceAll("(?i)\\b(нет\\s*данных|данные\\s*отсутствуют|нет\\s*информации)\\b", ""); + + // 3) Optionally remove any leftover placeholders + if (removePlaceholders) { + cleaned = cleaned.replaceAll("(?i)\\[\\[\\s*(CHART|TABLE)_[A-Za-z0-9_]+\\s*\\]\\]", ""); + } + + // Normalize whitespace + cleaned = cleaned.replaceAll("(\\r?\\n){3,}", "\n\n").trim(); + return cleaned; + } + /** * Удаляет «пустые» фразы-заглушки про графики, если рядом нет плейсхолдера * [[CHART_...]] / [[TABLE_...]] или ```json: блока. @@ -1547,6 +1595,11 @@ public class MarketingAnalysisService { } } } + // Для канальной матрицы (heatmap) нужен аудиторийный контекст (segments + + // segmentsChannelMatrix) + if (fullJson.containsKey("audienceAnalysis")) { + context.put("audienceAnalysis", fullJson.get("audienceAnalysis")); + } // Даже если channelsPotential отсутствует/пустой — даём опорный контекст (для // гипотез) if (fullJson.containsKey("strategyAnalysis")) { @@ -1567,6 +1620,43 @@ public class MarketingAnalysisService { if (fullJson.containsKey("conversionFunnel")) { context.put("conversionFunnel", fullJson.get("conversionFunnel")); } + // Alias: если conversionFunnel отсутствует в полном JSON, но есть + // strategyAnalysis.funnel.stages — + // собираем conversionFunnel в формате, ожидаемом графиком [[CHART_FUNNEL]]. + if (!context.containsKey("conversionFunnel") && fullJson.containsKey("strategyAnalysis")) { + try { + @SuppressWarnings("unchecked") + Map strategyAnalysis = (Map) fullJson.get("strategyAnalysis"); + if (strategyAnalysis != null && strategyAnalysis.get("funnel") instanceof Map) { + @SuppressWarnings("unchecked") + Map funnel = (Map) strategyAnalysis.get("funnel"); + if (funnel != null && funnel.get("stages") instanceof List) { + @SuppressWarnings("unchecked") + List> stages = (List>) funnel.get("stages"); + if (stages != null && !stages.isEmpty()) { + List> funnelList = new ArrayList<>(); + for (Map stage : stages) { + Map stageData = new HashMap<>(); + stageData.put("stage", stage.getOrDefault("stageName", "Этап")); + stageData.put("value", stage.getOrDefault("value", 0)); + Object conversionObj = stage.get("conversionPercent"); + if (conversionObj instanceof Number) { + stageData.put("conversion", ((Number) conversionObj).intValue()); + } else { + stageData.put("conversion", 0); + } + funnelList.add(stageData); + } + if (!funnelList.isEmpty()) { + context.put("conversionFunnel", funnelList); + } + } + } + } + } catch (Exception ignored) { + // Safe fallback: do not fail section context preparation + } + } // Даже если funnel отсутствует/пустой — даём опорный контекст (для гипотез) if (fullJson.containsKey("strategyAnalysis")) { context.put("strategyAnalysis", fullJson.get("strategyAnalysis")); @@ -1660,6 +1750,7 @@ public class MarketingAnalysisService { String jsonString = objectMapper.writeValueAsString(contextJson); StringBuilder promptBuilder = new StringBuilder(); promptBuilder.append("Ты — высокооплачиваемый бизнес-консультант и маркетинговый аналитик.\n\n"); + appendUltimateJsonPresenceInstruction(promptBuilder); appendMandatoryJsonUsageWarning(promptBuilder); appendPremiumConsultingStyleGuide(promptBuilder); promptBuilder @@ -1713,6 +1804,7 @@ public class MarketingAnalysisService { String jsonString = objectMapper.writeValueAsString(contextJson); StringBuilder promptBuilder = new StringBuilder(); promptBuilder.append("Ты — высокооплачиваемый бизнес-консультант и маркетинговый аналитик.\n\n"); + appendUltimateJsonPresenceInstruction(promptBuilder); appendMandatoryJsonUsageWarning(promptBuilder); appendPremiumConsultingStyleGuide(promptBuilder); promptBuilder @@ -1729,7 +1821,7 @@ public class MarketingAnalysisService { promptBuilder.append( "6. Объясни, что эти цифры значат для окупаемости фитнес-центра (логика окупаемости/скорость набора клиентов/масштаб спроса),\n"); promptBuilder.append( - " но НЕ придумывай новые финансовые цифры, если их нет в JSON — используй качественные выводы + \"нет данных в JSON\".\n\n"); + " но НЕ придумывай новые финансовые цифры: если в JSON нет чисел для части расчета — используй качественные выводы + аналитическую экстраполяцию (пометь как \"Экстраполяция\").\n\n"); promptBuilder.append("СТРУКТУРА РАЗДЕЛА:\n"); promptBuilder.append("II. Анализ продукта\n\n"); promptBuilder.append("Детально проанализируй:\n"); @@ -1771,6 +1863,7 @@ public class MarketingAnalysisService { String jsonString = objectMapper.writeValueAsString(contextJson); StringBuilder promptBuilder = new StringBuilder(); promptBuilder.append("Ты — высокооплачиваемый бизнес-консультант и маркетинговый аналитик.\n\n"); + appendUltimateJsonPresenceInstruction(promptBuilder); appendMandatoryJsonUsageWarning(promptBuilder); appendPremiumConsultingStyleGuide(promptBuilder); promptBuilder.append( @@ -1816,7 +1909,7 @@ public class MarketingAnalysisService { promptBuilder.append("- Используй данные из полей seasonality, market, regions\n"); promptBuilder.append("- Пиши максимально подробно, анализируй тренды\n"); promptBuilder.append( - "- Каждое ключевое утверждение подтверждай конкретной цифрой из JSON (или явно помечай \"нет данных в JSON\")\n\n"); + "- Каждое ключевое утверждение подтверждай конкретной цифрой из JSON; если нужна связующая метрика — делай экстраполяцию и помечай как \"Экстраполяция\".\n\n"); promptBuilder.append("JSON ДАННЫЕ:\n"); promptBuilder.append(jsonString); @@ -1844,6 +1937,7 @@ public class MarketingAnalysisService { String jsonString = objectMapper.writeValueAsString(contextJson); StringBuilder promptBuilder = new StringBuilder(); promptBuilder.append("Ты — высокооплачиваемый бизнес-консультант и маркетинговый аналитик.\n\n"); + appendUltimateJsonPresenceInstruction(promptBuilder); promptBuilder.append( "КРИТИЧЕСКАЯ ИНСТРУКЦИЯ: Данные в JSON ЕСТЬ. Если ты скажешь, что данных нет — это будет ошибкой. " + "Ты обязан найти объект `audienceAnalysis` в переданном тебе тексте. " @@ -1885,7 +1979,7 @@ public class MarketingAnalysisService { promptBuilder.append( "- Объясни, почему именно эти группы принесут больше денег (через частоту, удержание, вероятность апсейла, LTV).\n"); promptBuilder.append( - "- Если финансовых метрик нет в JSON — прямо пиши \"нет данных в JSON\" и объясняй методику расчета.\n"); + "- Если финансовые метрики не представлены явно: выполни аналитическую экстраполяцию (LTV/AvgCheck/частота) и опиши методику, пометив блок как \"Экстраполяция\".\n"); promptBuilder.append("- Демографические характеристики (возраст, пол, доход)\n"); promptBuilder.append("- Психографический портрет (потребности, мотивация, ценности)\n"); promptBuilder.append( @@ -1948,6 +2042,7 @@ public class MarketingAnalysisService { String jsonString = objectMapper.writeValueAsString(contextJson); StringBuilder promptBuilder = new StringBuilder(); promptBuilder.append("Ты — высокооплачиваемый бизнес-консультант и маркетинговый аналитик.\n\n"); + appendUltimateJsonPresenceInstruction(promptBuilder); promptBuilder.append( "КРИТИЧЕСКАЯ ИНСТРУКЦИЯ: Данные в JSON ЕСТЬ. Если ты скажешь, что данных нет — это будет ошибкой. " + "Ты обязан найти объект `competitorAnalysis` в переданном тебе тексте. " @@ -1960,7 +2055,7 @@ public class MarketingAnalysisService { promptBuilder.append("ТРЕБОВАНИЯ:\n"); promptBuilder.append( "ВНИМАНИЕ: Ты ОБЯЗАН вставить строку плейсхолдера [[CHART_...]] (или [[TABLE_...]]) строго ВНУТРИ текста раздела, сразу после абзаца, который описывает эти данные. НЕ вставляй это в начало или конец. Это критично для верстки.\n\n"); - promptBuilder.append("1. Объем: минимум 500-700 слов\n"); + promptBuilder.append("1. Объем: минимум 800-1100 слов\n"); promptBuilder.append("2. Стиль: деловой, убедительный для владельца бизнеса\n"); promptBuilder.append("3. Глубина: аналитические инсайты с практическими выводами\n"); promptBuilder.append("4. Данные: используй ТОЛЬКО данные из JSON, не выдумывай цифры\n"); @@ -1977,7 +2072,11 @@ public class MarketingAnalysisService { promptBuilder.append( "7. Проведи детальный баттл: Наш бренд vs Конкурент А vs Конкурент B. Используй данные из [[TABLE_COMPARISON]].\n"); promptBuilder.append( - "8. Проанализируй график долей рынка [[CHART_MARKET_SHARE]] и предложи стратегию по \"откусыванию\" доли у лидера (что именно меняем: оффер/цена/продукт/каналы/контент).\n\n"); + "8. Ты ОБЯЗАН сравнить наш бренд с Конкурентом A по КАЖДОЙ строке таблицы и сделать вывод (где мы сильнее/слабее, чем это монетизировать).\n"); + promptBuilder.append( + "9. РАСЧЕТ (ОБЯЗАТЕЛЬНО): рассчитай разрыв в охвате в процентах относительно НАС: $$ gap\\% = \\frac{Reach_{A}-Reach_{Us}}{Reach_{Us}}\\times 100\\% $$. Подставь числа из competitorAnalysis.competitorsDetails.digitalMetrics.reach (или ближайший числовой прокси из JSON) и интерпретируй бизнес-эффект.\n\n"); + promptBuilder.append( + "10. Проанализируй график долей рынка [[CHART_MARKET_SHARE]] и предложи стратегию по \"откусыванию\" доли у лидера (что именно меняем: оффер/цена/продукт/каналы/контент).\n\n"); promptBuilder.append("СТРУКТУРА РАЗДЕЛА:\n"); promptBuilder.append("V. Анализ конкурентов\n\n"); promptBuilder.append("Детально проанализируй:\n"); @@ -2012,15 +2111,13 @@ public class MarketingAnalysisService { "- Возьми двух главных конкурентов из competitorAnalysis (по доле рынка или по описанию) и назови их в тексте как \"Конкурент A\" и \"Конкурент B\".\n"); promptBuilder.append( "- Пройди ПО КАЖДОЙ строке competitorAnalysis.comparisonTable: Мы vs Конкурент A vs Конкурент B (с цифрами/формулировками из JSON) + практический вывод.\n\n"); - promptBuilder.append( - "ЕСЛИ ДАННЫХ НЕ ХВАТАЕТ:\n- Если marketShareChart/comparisonTable отсутствуют или пустые: явно напиши \"нет данных в JSON\" и предложи план сбора данных + гипотезы (с пометкой \"Гипотеза:\").\n\n"); promptBuilder.append("ВАЖНО:\n"); promptBuilder.append("- Начинай сразу с заголовка \"V. Анализ конкурентов\"\n"); promptBuilder.append("- Не добавляй вступления или заключения\n"); promptBuilder.append( "- Используй данные из competitorAnalysis.marketShareChart и competitorAnalysis.comparisonTable\n"); promptBuilder.append( - "- Не выдумывай цифры: если по строке сравнения нет данных — явно пометь это\n\n"); + "- Не выдумывай цифры. Если в конкретной строке/метрике нет явного значения — делай консервативную экстраполяцию и помечай как \"Экстраполяция\" (без фраз про отсутствие данных).\n\n"); promptBuilder.append("JSON ДАННЫЕ:\n"); promptBuilder.append(jsonString); @@ -2048,6 +2145,7 @@ public class MarketingAnalysisService { String jsonString = objectMapper.writeValueAsString(contextJson); StringBuilder promptBuilder = new StringBuilder(); promptBuilder.append("Ты — высокооплачиваемый бизнес-консультант и маркетинговый аналитик.\n\n"); + appendUltimateJsonPresenceInstruction(promptBuilder); appendMandatoryJsonUsageWarning(promptBuilder); appendPremiumConsultingStyleGuide(promptBuilder); promptBuilder.append("На основе следующего JSON напиши РАЗДЕЛ VI (SWOT-анализ) маркетингового отчета.\n\n"); @@ -2114,12 +2212,13 @@ public class MarketingAnalysisService { String jsonString = objectMapper.writeValueAsString(contextJson); StringBuilder promptBuilder = new StringBuilder(); promptBuilder.append("Ты — высокооплачиваемый бизнес-консультант и маркетинговый аналитик.\n\n"); + appendUltimateJsonPresenceInstruction(promptBuilder); appendMandatoryJsonUsageWarning(promptBuilder); appendPremiumConsultingStyleGuide(promptBuilder); promptBuilder.append( "На основе следующего JSON напиши РАЗДЕЛ VII (Каналы продвижения и их потенциал) маркетингового отчета.\n\n"); promptBuilder.append("ТРЕБОВАНИЯ:\n"); - promptBuilder.append("1. Объем: минимум 500-700 слов\n"); + promptBuilder.append("1. Объем: минимум 600-900 слов\n"); promptBuilder.append("2. Стиль: деловой, убедительный для владельца бизнеса\n"); promptBuilder.append("3. Глубина: аналитические инсайты с практическими выводами\n"); promptBuilder.append("4. Данные: используй ТОЛЬКО данные из JSON, не выдумывай цифры\n"); @@ -2127,7 +2226,7 @@ public class MarketingAnalysisService { promptBuilder.append( " - Если в JSON отсутствуют channels/channelsPotential: составь список каналов как ГИПОТЕЗЫ, опираясь на нишу/регион/цель, аудиторию (audienceAnalysis), сезонность и конкурентов.\n"); promptBuilder.append( - " - Если по каналу нет чисел: явно укажи \"нет данных в JSON\" и перечисли, какие метрики нужно собрать (CPC/CPM/CTR/CR/CPL/CPA/LTV).\n"); + " - Если по каналу нет чисел: делай консервативную экстраполяцию (помечай как \"Экстраполяция\") и перечисли, какие метрики нужно собрать (CPC/CPM/CTR/CR/CPL/CPA/LTV).\n"); promptBuilder.append( " - Если channelsPotential присутствует: обязательно используй potentialIndex/priority/обоснование.\n\n"); promptBuilder.append("СТРУКТУРА РАЗДЕЛА:\n"); @@ -2140,6 +2239,11 @@ public class MarketingAnalysisService { promptBuilder.append("- Рекомендации по использованию каждого канала\n"); promptBuilder.append("- Бюджетные рекомендации и ожидаемая эффективность\n"); promptBuilder.append("- Интеграция каналов в единую маркетинговую стратегию\n\n"); + promptBuilder.append("КРИТИЧНО (ОБЯЗАТЕЛЬНЫЕ ТЕГИ):\n"); + promptBuilder.append( + "- Ты ОБЯЗАН вставить [[CHART_CHANNELS]] и [[CHART_CHANNEL_MATRIX]] внутри текста (рядом с выводами по цифрам).\n"); + promptBuilder.append( + "- [[CHART_CHANNEL_MATRIX]] относится к segmentsChannelMatrix (heatmap сегмент×канал). Используй её для причинно-следственных выводов.\n\n"); promptBuilder.append("КРИТИЧНО - ВСТАВКА ГРАФИКА:\n"); promptBuilder.append( "1. Сразу после абзаца, где ты сравниваешь/ранжируешь каналы с конкретными аргументами, вставь тег [[CHART_CHANNELS]] рядом с этим описанием (внутри абзаца или сразу после него)\n"); @@ -2147,10 +2251,16 @@ public class MarketingAnalysisService { "2. ПЕРЕД графиком напиши вводный абзац (2-3 предложения), начинающийся с: \"Анализ данных на графике показывает...\"\n"); promptBuilder.append( "3. ПОСЛЕ графика напиши интерпретацию данных (2-3 предложения), начинающуюся с: \"Из этих показателей следует, что...\"\n\n"); + promptBuilder.append("ОБЯЗАТЕЛЬНЫЙ АНАЛИЗ ПО МАТРИЦЕ (SEGMENTS×CHANNELS):\n"); + promptBuilder.append( + "- Вставь [[CHART_CHANNEL_MATRIX]] рядом с абзацем, где ты объясняешь поведение сегментов в каналах.\n"); + promptBuilder.append( + "- Объясни на основе матрицы, почему для сегмента \"Молодежь\" приоритет Instagram выше, чем Facebook (с привязкой к значениям matrix).\n\n"); promptBuilder.append("ВАЖНО:\n"); promptBuilder.append("- Начинай сразу с заголовка \"VII. Каналы продвижения и их потенциал\"\n"); promptBuilder.append("- Не добавляй вступления или заключения\n"); promptBuilder.append("- Используй данные из полей channels, channelsPotential\n"); + promptBuilder.append("- Используй audienceAnalysis.segmentsChannelMatrix (если есть) для обоснования приоритетов.\n"); promptBuilder.append("- Для каждого канала дай конкретные рекомендации\n\n"); promptBuilder.append("JSON ДАННЫЕ:\n"); promptBuilder.append(jsonString); @@ -2179,6 +2289,7 @@ public class MarketingAnalysisService { String jsonString = objectMapper.writeValueAsString(contextJson); StringBuilder promptBuilder = new StringBuilder(); promptBuilder.append("Ты — высокооплачиваемый бизнес-консультант и маркетинговый аналитик.\n\n"); + appendUltimateJsonPresenceInstruction(promptBuilder); appendMandatoryJsonUsageWarning(promptBuilder); appendPremiumConsultingStyleGuide(promptBuilder); promptBuilder @@ -2186,20 +2297,25 @@ public class MarketingAnalysisService { promptBuilder.append("ТРЕБОВАНИЯ:\n"); promptBuilder.append( "ВНИМАНИЕ: Ты ОБЯЗАН вставить строку плейсхолдера [[CHART_...]] (или [[TABLE_...]]) строго ВНУТРИ текста раздела, сразу после абзаца, который описывает эти данные. НЕ вставляй это в начало или конец. Это критично для верстки.\n\n"); - promptBuilder.append("1. Объем: минимум 500-700 слов\n"); + promptBuilder.append("1. Объем: минимум 600-900 слов\n"); promptBuilder.append("2. Стиль: деловой, убедительный для владельца бизнеса\n"); promptBuilder.append("3. Глубина: аналитические инсайты с практическими выводами\n"); promptBuilder.append("4. Данные: используй ТОЛЬКО данные из JSON, не выдумывай цифры\n"); promptBuilder.append("ОБЯЗАТЕЛЬНАЯ СТРОКА:\n"); promptBuilder.append( "- Обязательно вставь тег [[CHART_FUNNEL]] непосредственно рядом с описанием этапов/конверсий (внутри абзаца или сразу после него).\n\n"); + promptBuilder.append("МАТЕМАТИКА И LaTeX (ОБЯЗАТЕЛЬНО):\n"); + promptBuilder.append( + "- Все формулы в этом разделе пиши ТОЛЬКО в LaTeX.\n"); + promptBuilder.append( + "- Обязательно вставь фразу: \"Вероятная прибыль рассчитывается по формуле: $$ P = V \\times CR \\times AvgCheck $$\" и используй её в расчетах.\n\n"); promptBuilder.append( "ВНИМАНИЕ: Проверяй математическую логику. Количество покупок НЕ МОЖЕТ быть больше количества заявок. " + "Конверсия не может превышать 100%. Если данные в JSON противоречивы, используй логические значения " + "(например, конверсия 10%) и сделай пометку 'Требуется уточнение данных'.\n"); promptBuilder.append("5. РАЗДЕЛ НЕЛЬЗЯ ПРОПУСКАТЬ:\n"); promptBuilder.append( - " - Если в JSON нет funnel/conversionFunnel: построй воронку как ГИПОТЕЗУ (этапы + логика), но все числа замени на переменные (X1, X2...) и подпиши \"нет данных в JSON\".\n"); + " - Если в JSON нет funnel/conversionFunnel: построй воронку как ГИПОТЕЗУ (этапы + логика), но все числа замени на переменные (X1, X2...) и пометь блок как \"Экстраполяция\".\n"); promptBuilder.append( " - Обязательно добавь блок: какие данные нужно собрать для расчета (трафик, лиды, заявки, оплаты, CR по этапам, CPL/CPA).\n"); promptBuilder.append( @@ -2214,6 +2330,11 @@ public class MarketingAnalysisService { promptBuilder.append("- Как оптимизировать узкое место для увеличения общего конверсионного потока\n"); promptBuilder.append("- Ожидаемый эффект от оптимизации (количественные оценки)\n"); promptBuilder.append("- Интеграция воронки с каналами продвижения\n\n"); + promptBuilder.append("ОБЯЗАТЕЛЬНЫЙ РАСЧЕТ ПО ПЕРЕХОДУ \"Интерес → Заявка\":\n"); + promptBuilder.append( + "- Найди этапы \"Интерес\" и \"Заявка\" (или ближайшие по смыслу) и посчитай, сколько человек теряем: $$ Lost = V_{Interest} - V_{Lead} $$. \n"); + promptBuilder.append( + "- Оцени денежную стоимость потерь через экстраполяцию (например, Lost × CR_toPurchase × AvgCheck) и интерпретируй управленчески.\n\n"); promptBuilder.append("КРИТИЧНО - ВСТАВКА ГРАФИКА:\n"); promptBuilder.append( "1. Сразу после описания этапов и ключевых выводов по узким местам вставь тег [[CHART_FUNNEL]] рядом с этим описанием (внутри абзаца или сразу после него)\n"); @@ -2261,6 +2382,7 @@ public class MarketingAnalysisService { String jsonString = objectMapper.writeValueAsString(contextJson); StringBuilder promptBuilder = new StringBuilder(); promptBuilder.append("Ты — высокооплачиваемый бизнес-консультант и маркетинговый аналитик.\n\n"); + appendUltimateJsonPresenceInstruction(promptBuilder); appendMandatoryJsonUsageWarning(promptBuilder); appendPremiumConsultingStyleGuide(promptBuilder); promptBuilder.append( @@ -2317,6 +2439,7 @@ public class MarketingAnalysisService { String jsonString = objectMapper.writeValueAsString(contextJson); StringBuilder promptBuilder = new StringBuilder(); promptBuilder.append("Ты — высокооплачиваемый бизнес-консультант и маркетинговый аналитик.\n\n"); + appendUltimateJsonPresenceInstruction(promptBuilder); appendMandatoryJsonUsageWarning(promptBuilder); appendPremiumConsultingStyleGuide(promptBuilder); promptBuilder.append( @@ -2371,6 +2494,7 @@ public class MarketingAnalysisService { String jsonString = objectMapper.writeValueAsString(contextJson); StringBuilder promptBuilder = new StringBuilder(); promptBuilder.append("Ты — высокооплачиваемый бизнес-консультант и маркетинговый аналитик.\n\n"); + appendUltimateJsonPresenceInstruction(promptBuilder); appendMandatoryJsonUsageWarning(promptBuilder); appendPremiumConsultingStyleGuide(promptBuilder); promptBuilder.append( @@ -2392,6 +2516,10 @@ public class MarketingAnalysisService { promptBuilder.append("- Бюджетные рекомендации и распределение ресурсов\n"); promptBuilder.append("- Критические точки контроля и корректировки стратегии\n\n"); promptBuilder.append("КРИТИЧНО - ПРОГНОЗ И РАСЧЕТЫ НА ОСНОВЕ ВОРОНКИ:\n"); + promptBuilder.append("МАТЕМАТИКА И LaTeX (ОБЯЗАТЕЛЬНО):\n"); + promptBuilder.append("- Все формулы в этом разделе пиши ТОЛЬКО в LaTeX.\n"); + promptBuilder.append( + "- Обязательно вставь фразу: \"Вероятная прибыль рассчитывается по формуле: $$ P = V \\times CR \\times AvgCheck $$\".\n\n"); promptBuilder.append( "1. Возьми воронку из strategyAnalysis.funnel (или из funnel, если strategyAnalysis отсутствует) и выполни расчеты.\n"); promptBuilder.append( @@ -2403,7 +2531,7 @@ public class MarketingAnalysisService { promptBuilder.append( "3. Сделай 3 сценария: консервативный / базовый / оптимистичный (допуски только как проценты к значениям из JSON; не придумывай абсолютные значения).\n"); promptBuilder.append( - "4. Если данных для метрики нет в JSON — прямо напиши \"нет данных в JSON\" и не рассчитывай её.\n\n"); + "4. Если в JSON нет явного значения для части метрики — делай аккуратную экстраполяцию (пометь как \"Экстраполяция\") и показывай допущения.\n\n"); promptBuilder.append("ВАЖНО:\n"); promptBuilder .append("- Начинай сразу с заголовка \"XI. Итоговая стратегия: что делать в первую очередь\"\n"); @@ -3464,6 +3592,8 @@ public class MarketingAnalysisService { String textReport = fullReportBuilder.toString(); logger.info("generateMarketingReport: Modular text report generated. Length: {} chars", textReport != null ? textReport.length() : 0); + // Hard sanitize forbidden "no data" wording before placeholder replacement + textReport = hardSanitizeReportText(textReport, false); // Извлекаем данные для графиков из JSON logger.info("generateMarketingReport: Starting chart data parsing from JSON"); @@ -3496,8 +3626,11 @@ public class MarketingAnalysisService { .replace("[[CHART_AGE_DISTRIBUTION]]", "") .replace("[[CHART_GENDER_DISTRIBUTION]]", "") .replace("[[CHART_CHANNELS]]", "") + .replace("[[CHART_CHANNEL_MATRIX]]", "") .replace("[[CHART_FUNNEL]]", "") .replace("[[CHART_SWOT]]", ""); + processedTextReport = processedTextReport.replaceAll("(?i)\\[\\[\\s*(CHART|TABLE)_[A-Za-z0-9_]+\\s*\\]\\]", + ""); } // Сохраняем данные анализа @@ -3507,6 +3640,8 @@ public class MarketingAnalysisService { // Очищаем текст: ошибки/отказы + "пустые" фразы про графики без реального блока String cleanedTextReport = processedTextReport != null ? removeErrorMessages(processedTextReport) : null; cleanedTextReport = cleanedTextReport != null ? removeDanglingChartNarration(cleanedTextReport) : null; + // Final hard sanitize: remove forbidden "no data" wording and any leftover placeholders + cleanedTextReport = cleanedTextReport != null ? hardSanitizeReportText(cleanedTextReport, true) : null; if (cleanedTextReport != null && cleanedTextReport.trim().isEmpty()) { cleanedTextReport = "Анализ не удалось сгенерировать."; } @@ -3544,7 +3679,11 @@ public class MarketingAnalysisService { for (String roman : SECTION_ROMANS) { String id = ROMAN_TO_ANCHOR_ID.get(roman); String label = sectionTitles.getOrDefault(roman, "Раздел " + roman); - if ("V".equals(roman) && ("Раздел V".equals(label) || label == null || label.isBlank())) { + if ("V".equals(roman) + && (label == null || label.isBlank() + || "Раздел V".equals(label) + || "V. Раздел V".equals(label) + || label.toLowerCase(Locale.ROOT).contains("раздел v"))) { label = "V. Анализ конкурентов"; } markdown.append("- [").append(label).append("](#").append(id).append(")\n"); @@ -3661,8 +3800,11 @@ public class MarketingAnalysisService { .replace("[[CHART_AGE_DISTRIBUTION]]", "") .replace("[[CHART_GENDER_DISTRIBUTION]]", "") .replace("[[CHART_CHANNELS]]", "") + .replace("[[CHART_CHANNEL_MATRIX]]", "") .replace("[[CHART_FUNNEL]]", "") .replace("[[CHART_SWOT]]", ""); + // Safety net: remove any unknown/variant placeholders too + cleaned = cleaned.replaceAll("(?i)\\[\\[\\s*(CHART|TABLE)_[A-Za-z0-9_]+\\s*\\]\\]", ""); cleaned = cleaned.replaceAll("(\\r?\\n){3,}", "\n\n"); return cleaned; } @@ -3686,6 +3828,7 @@ public class MarketingAnalysisService { placeholderMap.put("[[CHART_AGE_DISTRIBUTION]]", new ChartMapping("audienceAge", "audienceAge")); placeholderMap.put("[[CHART_GENDER_DISTRIBUTION]]", new ChartMapping("audienceGender", "audienceGender")); placeholderMap.put("[[CHART_CHANNELS]]", new ChartMapping("channelsPotential", "channelsPotential")); + placeholderMap.put("[[CHART_CHANNEL_MATRIX]]", new ChartMapping("segmentsChannelMatrix", "segmentsChannelMatrix")); placeholderMap.put("[[CHART_FUNNEL]]", new ChartMapping("conversionFunnel", "funnel")); placeholderMap.put("[[CHART_SWOT]]", new ChartMapping("swot", "swot")); @@ -3781,6 +3924,8 @@ public class MarketingAnalysisService { result = removePlaceholderWithSurroundingNewlines(result, placeholderVariant); } + // Final safety net: remove any leftover placeholders (known or unknown) + result = result.replaceAll("(?i)\\[\\[\\s*(CHART|TABLE)_[A-Za-z0-9_]+\\s*\\]\\]", ""); result = result.replaceAll("(\\r?\\n){3,}", "\n\n"); logger.info( @@ -3816,7 +3961,7 @@ public class MarketingAnalysisService { romanToPlaceholders.put("IV", List.of("[[CHART_AGE_DISTRIBUTION]]", "[[CHART_GENDER_DISTRIBUTION]]")); romanToPlaceholders.put("V", List.of("[[CHART_MARKET_SHARE]]", "[[TABLE_COMPARISON]]")); romanToPlaceholders.put("VI", List.of("[[CHART_SWOT]]")); - romanToPlaceholders.put("VII", List.of("[[CHART_CHANNELS]]")); + romanToPlaceholders.put("VII", List.of("[[CHART_CHANNELS]]", "[[CHART_CHANNEL_MATRIX]]")); romanToPlaceholders.put("VIII", List.of("[[CHART_FUNNEL]]")); // DEDUP RULE: diff --git a/src/test/java/kz/konturai/parser/service/MarketingAnalysisServiceChartPlacementTest.java b/src/test/java/kz/konturai/parser/service/MarketingAnalysisServiceChartPlacementTest.java index d045d30..0060efe 100644 --- a/src/test/java/kz/konturai/parser/service/MarketingAnalysisServiceChartPlacementTest.java +++ b/src/test/java/kz/konturai/parser/service/MarketingAnalysisServiceChartPlacementTest.java @@ -33,6 +33,7 @@ class MarketingAnalysisServiceChartPlacementTest { placeholderMap.put("[[TABLE_COMPARISON]]", ctor.newInstance("comparisonTable", "comparisonTable")); placeholderMap.put("[[CHART_SWOT]]", ctor.newInstance("swot", "swot")); placeholderMap.put("[[CHART_CHANNELS]]", ctor.newInstance("channelsPotential", "channelsPotential")); + placeholderMap.put("[[CHART_CHANNEL_MATRIX]]", ctor.newInstance("segmentsChannelMatrix", "segmentsChannelMatrix")); placeholderMap.put("[[CHART_FUNNEL]]", ctor.newInstance("conversionFunnel", "funnel")); return placeholderMap; } @@ -43,6 +44,15 @@ class MarketingAnalysisServiceChartPlacementTest { return chartsData; } + private Map buildChartsDataChannelsAndMatrix() { + Map chartsData = new HashMap<>(); + chartsData.put("channelsPotential", Map.of("labels", java.util.List.of("Instagram"), "datasets", + java.util.List.of(Map.of("label", "Потенциал", "data", java.util.List.of(80))))); + chartsData.put("segmentsChannelMatrix", + java.util.List.of(Map.of("segmentName", "Молодежь", "instagram", "high", "facebook", "medium"))); + return chartsData; + } + @Test void addMissingChartsToSections_shouldIgnoreTocListItems_andUseRealMarkdownHeading() throws Exception { String report = "" @@ -109,4 +119,36 @@ class MarketingAnalysisServiceChartPlacementTest { assertEquals(report, out, "If any placeholder was processed, must not append any missing placeholders"); } + + @Test + void addMissingChartsToSections_shouldInsertChannelsAndMatrixIntoSectionVII() throws Exception { + String report = "" + + "## Оглавление\n" + + "- [VII. Каналы продвижения и их потенциал](#section-vii)\n" + + "\n" + + "## VII. Каналы продвижения и их потенциал\n" + + "Тут есть текст про каналы.\n" + + "\n" + + "## VIII. Воронка спроса\n" + + "Тут есть текст про воронку.\n"; + + Method m = MarketingAnalysisService.class.getDeclaredMethod("addMissingChartsToSections", String.class, + Map.class, + Map.class, Set.class); + m.setAccessible(true); + + String out = (String) m.invoke( + marketingAnalysisService, + report, + buildChartsDataChannelsAndMatrix(), + buildPlaceholderMap(), + new HashSet()); + + int s7Start = out.indexOf("## VII."); + int s8Start = out.indexOf("## VIII."); + assertTrue(s7Start >= 0 && s8Start > s7Start, "Sanity check: VII before VIII"); + String section7Body = out.substring(s7Start, s8Start); + assertTrue(section7Body.contains("[[CHART_CHANNELS]]"), "Must insert CHART_CHANNELS into section VII"); + assertTrue(section7Body.contains("[[CHART_CHANNEL_MATRIX]]"), "Must insert CHART_CHANNEL_MATRIX into section VII"); + } }