From 0f8a59f13ac417e96951caad5888e5caf0637908 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 20 Dec 2025 20:16:07 +0500 Subject: [PATCH] . --- .../service/MarketingAnalysisService.java | 251 ++++++++++++------ 1 file changed, 176 insertions(+), 75 deletions(-) diff --git a/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java b/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java index 6cae0ce..7aeb083 100644 --- a/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java +++ b/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java @@ -1283,6 +1283,77 @@ public class MarketingAnalysisService { return cleaned; } + /** + * Удаляет «пустые» фразы-заглушки про графики, если рядом нет плейсхолдера + * [[CHART_...]] / [[TABLE_...]] или ```json: блока. + * + * Мотивация: модель может написать «Анализ данных на графике ниже + * показывает...», но забыть + * вставить плейсхолдер. В результате в тексте остаётся пустая ссылка на «график + * ниже». + */ + private String removeDanglingChartNarration(String text) { + if (text == null || text.trim().isEmpty()) { + return text; + } + + String[] needles = { + "Анализ данных на графике ниже показывает", + "Из этих показателей следует, что" + }; + + String[] lines = text.split("\\r?\\n", -1); + List out = new ArrayList<>(lines.length); + + java.util.regex.Pattern placeholderPattern = java.util.regex.Pattern + .compile("\\[\\[\\s*(CHART|TABLE)_[A-Za-z0-9_]+\\s*\\]\\]", java.util.regex.Pattern.CASE_INSENSITIVE); + java.util.regex.Pattern jsonBlockStartPattern = java.util.regex.Pattern + .compile("```\\s*json\\s*:", java.util.regex.Pattern.CASE_INSENSITIVE); + + for (int i = 0; i < lines.length; i++) { + String line = lines[i]; + String trimmed = line != null ? line.trim() : ""; + + boolean isNarration = false; + for (String n : needles) { + if (trimmed.toLowerCase().contains(n.toLowerCase())) { + isNarration = true; + break; + } + } + + if (!isNarration) { + out.add(line); + continue; + } + + // Keep narration only if there is a placeholder or json block nearby (next 3 + // lines or same line). + boolean hasVisualNear = false; + int from = Math.max(0, i); + int to = Math.min(lines.length - 1, i + 3); + for (int j = from; j <= to; j++) { + String ctx = lines[j] != null ? lines[j] : ""; + if (placeholderPattern.matcher(ctx).find() || jsonBlockStartPattern.matcher(ctx).find()) { + hasVisualNear = true; + break; + } + } + + if (hasVisualNear) { + out.add(line); + } else { + // Drop this line (and if it's alone in a paragraph, later normalization will + // collapse empty lines) + continue; + } + } + + String cleaned = String.join("\n", out); + cleaned = cleaned.replaceAll("\\n{3,}", "\n\n").trim(); + return cleaned; + } + /** * Подготавливает контекстный JSON для конкретного раздела отчета. * Извлекает только релевантные данные для экономии токенов. @@ -1651,6 +1722,8 @@ public class MarketingAnalysisService { promptBuilder.append("2. Стиль: деловой, убедительный для владельца бизнеса\n"); promptBuilder.append("3. Глубина: аналитические инсайты с практическими выводами\n"); promptBuilder.append("4. Данные: используй ТОЛЬКО данные из JSON, не выдумывай цифры\n\n"); + promptBuilder.append("ОБЯЗАТЕЛЬНАЯ СТРОКА:\n"); + promptBuilder.append("- Обязательно напиши строку [[CHART_SEASONALITY]] (на отдельной строке).\n\n"); promptBuilder.append("СТРУКТУРА РАЗДЕЛА:\n"); promptBuilder.append("III. Анализ рынка и сезонности\n\n"); promptBuilder.append("Детально проанализируй:\n"); @@ -1671,9 +1744,11 @@ public class MarketingAnalysisService { promptBuilder.append( "1. В логичном месте раздела (после описания сезонности) вставь плейсхолдер [[CHART_SEASONALITY]] на отдельной строке\n"); promptBuilder.append( - "2. ПЕРЕД графиком напиши вводный абзац (2-3 предложения), начинающийся с: \"Анализ данных на графике ниже показывает...\"\n"); + "2. ПЕРЕД плейсхолдером напиши вводный абзац (2-3 предложения), но ТОЛЬКО если следующей строкой действительно идет [[CHART_SEASONALITY]].\n"); promptBuilder.append( - "3. ПОСЛЕ графика напиши интерпретацию данных (2-3 предложения), начинающуюся с: \"Из этих показателей следует, что...\"\n\n"); + "3. ПОСЛЕ плейсхолдера напиши интерпретацию данных (2-3 предложения), но ТОЛЬКО если в тексте присутствует [[CHART_SEASONALITY]].\n\n"); + promptBuilder.append( + "ЗАПРЕТ НА ЗАГЛУШКИ:\n- Запрещено писать фразы типа \"Анализ данных на графике ниже показывает...\" и \"Из этих показателей следует, что...\", если ты НЕ вставил строку [[CHART_SEASONALITY]].\n\n"); promptBuilder.append("ВАЖНО:\n"); promptBuilder.append("- Начинай сразу с заголовка \"III. Анализ рынка и сезонности\"\n"); promptBuilder.append("- Не добавляй вступления или заключения\n"); @@ -1724,6 +1799,9 @@ public class MarketingAnalysisService { promptBuilder.append("4. Данные: используй ТОЛЬКО данные из JSON, не выдумывай цифры\n"); promptBuilder.append( "5. ЦИФРЫ ОБЯЗАТЕЛЬНЫ: в каждом подпункте делай выводы на основе процентов/чисел из audienceAnalysis.ageGroups и audienceAnalysis.genderDistribution.\n\n"); + promptBuilder.append("ОБЯЗАТЕЛЬНЫЕ СТРОКИ:\n"); + promptBuilder.append( + "- Обязательно напиши строки [[CHART_AGE_DISTRIBUTION]] и [[CHART_GENDER_DISTRIBUTION]] (каждая на отдельной строке).\n\n"); promptBuilder.append("ЕСЛИ В JSON НЕТ ТОЧНЫХ ЦИФР (РАЗРЕШЕННАЯ АППРОКСИМАЦИЯ):\n"); promptBuilder.append( @@ -1756,15 +1834,17 @@ public class MarketingAnalysisService { promptBuilder.append( "1. После разбора возрастных групп вставь плейсхолдер [[CHART_AGE_DISTRIBUTION]] на отдельной строке\n"); promptBuilder.append( - " - ПЕРЕД графиком: вводный абзац (2-3 предложения), начинающийся с: \"Анализ данных на графике ниже показывает...\"\n"); + " - ПЕРЕД плейсхолдером: вводный абзац (2-3 предложения), но ТОЛЬКО если следующей строкой идет [[CHART_AGE_DISTRIBUTION]].\n"); promptBuilder.append( - " - ПОСЛЕ графика: интерпретация (2-3 предложения), начинающаяся с: \"Из этих показателей следует, что...\"\n"); + " - ПОСЛЕ плейсхолдера: интерпретация (2-3 предложения), но ТОЛЬКО если в тексте присутствует [[CHART_AGE_DISTRIBUTION]].\n"); promptBuilder.append( "2. После разбора гендерного распределения вставь плейсхолдер [[CHART_GENDER_DISTRIBUTION]] на отдельной строке\n"); promptBuilder.append( - " - ПЕРЕД графиком: вводный абзац (2-3 предложения), начинающийся с: \"Анализ данных на графике ниже показывает...\"\n"); + " - ПЕРЕД плейсхолдером: вводный абзац (2-3 предложения), но ТОЛЬКО если следующей строкой идет [[CHART_GENDER_DISTRIBUTION]].\n"); promptBuilder.append( - " - ПОСЛЕ графика: интерпретация (2-3 предложения), начинающаяся с: \"Из этих показателей следует, что...\"\n\n"); + " - ПОСЛЕ плейсхолдера: интерпретация (2-3 предложения), но ТОЛЬКО если в тексте присутствует [[CHART_GENDER_DISTRIBUTION]].\n\n"); + promptBuilder.append( + "ЗАПРЕТ НА ЗАГЛУШКИ:\n- Запрещено писать фразы типа \"Анализ данных на графике ниже показывает...\" и \"Из этих показателей следует, что...\", если ты НЕ вставил соответствующий плейсхолдер графика.\n\n"); promptBuilder.append( "ОБЯЗАТЕЛЬНО ПРО ГЕНДЕР:\n- Явно укажи долю женщин по данным genderDistribution (в типовом кейсе около 60%) и объясни, почему аудитория смещена в сторону женщин.\n"); promptBuilder.append( @@ -1821,6 +1901,9 @@ public class MarketingAnalysisService { promptBuilder.append("4. Данные: используй ТОЛЬКО данные из JSON, не выдумывай цифры\n"); promptBuilder.append( "5. Это должен быть самый мощный раздел отчёта: каждое утверждение подтверждай цифрами из competitorAnalysis.marketShareChart / competitorAnalysis.comparisonTable.\n\n"); + promptBuilder.append("ОБЯЗАТЕЛЬНЫЕ СТРОКИ:\n"); + promptBuilder.append( + "- Обязательно напиши строки [[CHART_MARKET_SHARE]] и [[TABLE_COMPARISON]] (каждая на отдельной строке).\n\n"); promptBuilder.append( "КРИТИЧНО (ОБЯЗАТЕЛЬНЫЕ ТЕГИ): Ты ОБЯЗАН вставить тег [[CHART_MARKET_SHARE]] в начале раздела и [[TABLE_COMPARISON]] в середине. Без этих тегов отчет будет неполным.\n"); promptBuilder.append( @@ -1847,15 +1930,17 @@ public class MarketingAnalysisService { promptBuilder.append( "1. В НАЧАЛЕ раздела (сразу после заголовка V. Анализ конкурентов) вставь плейсхолдер [[CHART_MARKET_SHARE]] на отдельной строке\n"); promptBuilder.append( - " - ПЕРЕД графиком: вводный абзац (2-3 предложения), начинающийся с: \"Анализ данных на графике ниже показывает...\"\n"); + " - ПЕРЕД плейсхолдером: вводный абзац (2-3 предложения), но ТОЛЬКО если следующей строкой идет [[CHART_MARKET_SHARE]].\n"); promptBuilder.append( - " - ПОСЛЕ графика: интерпретация (2-3 предложения), начинающаяся с: \"Из этих показателей следует, что...\"\n"); + " - ПОСЛЕ плейсхолдера: интерпретация (2-3 предложения), но ТОЛЬКО если в тексте присутствует [[CHART_MARKET_SHARE]].\n"); promptBuilder.append( "2. В СЕРЕДИНЕ раздела вставь плейсхолдер [[TABLE_COMPARISON]] на отдельной строке (это сравнительная таблица)\n"); promptBuilder.append( " - ПЕРЕД таблицей: короткий ввод (1-2 предложения), что именно сравниваем\n"); promptBuilder.append( " - ПОСЛЕ таблицы: интерпретация (3-5 предложений) с выводом по позиционированию\n\n"); + promptBuilder.append( + "ЗАПРЕТ НА ЗАГЛУШКИ:\n- Запрещено писать фразы типа \"Анализ данных на графике ниже показывает...\" и \"Из этих показателей следует, что...\", если ты НЕ вставил строку [[CHART_MARKET_SHARE]].\n\n"); promptBuilder.append( "ОБЯЗАТЕЛЬНОЕ СРАВНЕНИЕ С 'КОНКУРЕНТОМ A' И 'КОНКУРЕНТОМ B':\n"); promptBuilder.append( @@ -2038,6 +2123,8 @@ public class MarketingAnalysisService { promptBuilder.append("2. Стиль: деловой, убедительный для владельца бизнеса\n"); promptBuilder.append("3. Глубина: аналитические инсайты с практическими выводами\n"); promptBuilder.append("4. Данные: используй ТОЛЬКО данные из JSON, не выдумывай цифры\n"); + promptBuilder.append("ОБЯЗАТЕЛЬНАЯ СТРОКА:\n"); + promptBuilder.append("- Обязательно напиши строку [[CHART_FUNNEL]] (на отдельной строке).\n\n"); promptBuilder.append( "ВНИМАНИЕ: Проверяй математическую логику. Количество покупок НЕ МОЖЕТ быть больше количества заявок. " + "Конверсия не может превышать 100%. Если данные в JSON противоречивы, используй логические значения " @@ -2063,9 +2150,11 @@ public class MarketingAnalysisService { promptBuilder.append( "1. В логичном месте раздела (после описания этапов) вставь плейсхолдер [[CHART_FUNNEL]] на отдельной строке\n"); promptBuilder.append( - "2. ПЕРЕД графиком напиши вводный абзац (2-3 предложения), начинающийся с: \"Анализ данных на графике ниже показывает...\"\n"); + "2. ПЕРЕД плейсхолдером напиши вводный абзац (2-3 предложения), но ТОЛЬКО если следующей строкой идет [[CHART_FUNNEL]].\n"); promptBuilder.append( - "3. ПОСЛЕ графика напиши интерпретацию данных (2-3 предложения), начинающуюся с: \"Из этих показателей следует, что...\"\n\n"); + "3. ПОСЛЕ плейсхолдера напиши интерпретацию данных (2-3 предложения), но ТОЛЬКО если в тексте присутствует [[CHART_FUNNEL]].\n\n"); + promptBuilder.append( + "ЗАПРЕТ НА ЗАГЛУШКИ:\n- Запрещено писать фразы типа \"Анализ данных на графике ниже показывает...\" и \"Из этих показателей следует, что...\", если ты НЕ вставил строку [[CHART_FUNNEL]].\n\n"); promptBuilder.append("ВАЖНО:\n"); promptBuilder.append("- Начинай сразу с заголовка \"VIII. Воронка спроса\"\n"); promptBuilder.append("- Не добавляй вступления или заключения\n"); @@ -3347,8 +3436,9 @@ public class MarketingAnalysisService { report.put("analysisTypes", analysisTypeNames); report.put("detailLevel", detailLevelName); - // Очищаем текст от сообщений об ошибках AI перед сохранением + // Очищаем текст: ошибки/отказы + "пустые" фразы про графики без реального блока String cleanedTextReport = processedTextReport != null ? removeErrorMessages(processedTextReport) : null; + cleanedTextReport = cleanedTextReport != null ? removeDanglingChartNarration(cleanedTextReport) : null; if (cleanedTextReport != null && cleanedTextReport.trim().isEmpty()) { cleanedTextReport = "Анализ не удалось сгенерировать."; } @@ -3647,55 +3737,74 @@ public class MarketingAnalysisService { private String addMissingChartsToSections(String text, Map chartsData, Map placeholderMap, Set processedPlaceholders) { String result = text; + if (result == null || result.isBlank()) { + return result; + } - Map> sectionToPlaceholders = new HashMap<>(); - // NOTE: Используем regex-паттерны, чтобы совпасть с вариациями заголовков, - // которые может генерировать модель - sectionToPlaceholders.put("III\\.\\s*Анализ рынка и сезонности", List.of("[[CHART_SEASONALITY]]")); - sectionToPlaceholders.put("IV\\.\\s*Анализ целевой аудитории", - List.of("[[CHART_AGE_DISTRIBUTION]]", "[[CHART_GENDER_DISTRIBUTION]]")); - sectionToPlaceholders.put("V\\.\\s*Анализ конкурентов", - List.of("[[CHART_MARKET_SHARE]]", "[[TABLE_COMPARISON]]")); - sectionToPlaceholders.put("VII\\.\\s*Каналы продвижения", List.of("[[CHART_CHANNELS]]")); - sectionToPlaceholders.put("VIII\\.\\s*Воронка спроса", List.of("[[CHART_FUNNEL]]")); - // SWOT может быть написан как SWOT-анализ / SWOT анализ / SWОT-анализ, поэтому - // делаем мягче - sectionToPlaceholders.put("VI\\.\\s*SWOT[-\\s]?анализ", List.of("[[CHART_SWOT]]")); + // Section -> required placeholders by Roman numeral. + // Must work for headings like: + // - "III. Анализ рынка..." + // - "## III. Анализ рынка..." + // - "## V. Анализ конкурентов" + Map> romanToPlaceholders = new HashMap<>(); + romanToPlaceholders.put("III", List.of("[[CHART_SEASONALITY]]")); + 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("VIII", List.of("[[CHART_FUNNEL]]")); - // Умный fallback: если модель НЕ ВСТАВИЛА плейсхолдер в текст секции, - // а данные для него есть — добавляем сам плейсхолдер в КОНЕЦ секции. - // Далее replaceChartPlaceholders() (final pass) заменит плейсхолдер на - // ```json: блок. - for (Map.Entry> sectionEntry : sectionToPlaceholders.entrySet()) { - String sectionPattern = sectionEntry.getKey(); - List placeholders = sectionEntry.getValue(); + // Find all section headings by Roman numerals. Allow optional markdown heading + // prefix. + // Example matches: + // "## III. Анализ рынка и сезонности" + // "V. Анализ конкурентов" + java.util.regex.Pattern sectionHeader = java.util.regex.Pattern.compile( + "(?im)^(?:#{2,6}\\s*)?\\s*([IVX]{1,6})\\s*\\.\\s*.*$"); + java.util.regex.Matcher m = sectionHeader.matcher(result); + + class SectionRange { + final int start; + final String roman; + + SectionRange(int start, String roman) { + this.start = start; + this.roman = roman; + } + } + + List headers = new ArrayList<>(); + while (m.find()) { + String roman = m.group(1) != null ? m.group(1).toUpperCase() : null; + if (roman == null || roman.isBlank()) { + continue; + } + if (!romanToPlaceholders.containsKey(roman)) { + continue; + } + headers.add(new SectionRange(m.start(), roman)); + } + + if (headers.isEmpty()) { + logger.debug("addMissingChartsToSections: No target sections found by roman headers"); + return result; + } + + // Insert from bottom to top so indices stay valid. + for (int i = headers.size() - 1; i >= 0; i--) { + SectionRange h = headers.get(i); + int sectionStart = h.start; + int sectionEnd = (i < headers.size() - 1) ? headers.get(i + 1).start : result.length(); + if (sectionStart < 0 || sectionEnd <= sectionStart || sectionEnd > result.length()) { + continue; + } + + String sectionText = result.substring(sectionStart, sectionEnd); + List placeholders = romanToPlaceholders.get(h.roman); if (placeholders == null || placeholders.isEmpty()) { continue; } - // 1) Найти старт секции по заголовку (с ## или без) - java.util.regex.Pattern sectionStartPattern = java.util.regex.Pattern.compile( - "(?im)^(?:##+\\s*)?" + sectionPattern + ".*$"); - java.util.regex.Matcher sectionStartMatcher = sectionStartPattern.matcher(result); - if (!sectionStartMatcher.find()) { - logger.debug("addMissingChartsToSections: Section not found for pattern: {}", sectionPattern); - continue; - } - - int sectionStart = sectionStartMatcher.start(); - int scanFrom = sectionStartMatcher.end(); - - // 2) Найти конец секции (следующий заголовок вида IV./V./VI... или Markdown - // heading) - java.util.regex.Pattern nextSectionPattern = java.util.regex.Pattern.compile( - "(?im)^(?:##+\\s*)?[IVX]+\\.\\s+.*$"); - java.util.regex.Matcher nextSectionMatcher = nextSectionPattern.matcher(result); - int sectionEnd = result.length(); - if (nextSectionMatcher.find(scanFrom)) { - sectionEnd = nextSectionMatcher.start(); - } - - String sectionText = result.substring(sectionStart, sectionEnd); StringBuilder tagsToAppend = new StringBuilder(); boolean willAppendAnything = false; @@ -3708,22 +3817,18 @@ public class MarketingAnalysisService { continue; } - // Если график уже был обработан (заменен на ```json блок в другой части - // текста), - // то НИ В КОЕМ СЛУЧАЕ не добавляем его повторно в конец секции. String normalizedPlaceholder = normalizePlaceholderToken(placeholder); if (processedPlaceholders != null && processedPlaceholders.contains(normalizedPlaceholder)) { continue; } - // Если плейсхолдер уже есть в тексте секции (даже с пробелами/регистром) — не - // добавляем + // If placeholder already exists in section (even with whitespace/case + // variants), skip. if (sectionContainsPlaceholder(sectionText, placeholder)) { continue; } - // Если JSON-блок для этого графика уже присутствует в секции — значит он уже - // вставлен/обработан; не добавляем повторно в конец секции + // If json block already exists in this section, mark as processed and skip. if (mapping.chartKey != null && sectionContainsJsonBlock(sectionText, mapping.chartKey)) { if (processedPlaceholders != null) { processedPlaceholders.add(normalizedPlaceholder); @@ -3731,30 +3836,27 @@ public class MarketingAnalysisService { continue; } - // Если данные отсутствуют — не добавляем тег (он будет удален final pass-ом) + // Only append if data exists and is not empty. if (chartsData == null || !chartsData.containsKey(mapping.dataKey)) { logger.warn( - "addMissingChartsToSections: chartsData missing key '{}' for placeholder {} in section {}", - mapping.dataKey, placeholder, sectionPattern); + "addMissingChartsToSections: chartsData missing key '{}' for placeholder {} in roman section {}", + mapping.dataKey, placeholder, h.roman); continue; } - Object chartData = chartsData.get(mapping.dataKey); if (isEmptyChartData(chartData)) { logger.warn( - "addMissingChartsToSections: chartData empty for key '{}' (placeholder {}) in section {}", - mapping.dataKey, placeholder, sectionPattern); + "addMissingChartsToSections: chartData empty for key '{}' (placeholder {}) in roman section {}", + mapping.dataKey, placeholder, h.roman); continue; } - // Добавляем тег в конец секции. Это решает кейс: «модель забыла вставить - // [[TABLE_COMPARISON]]» + // Force append to the very end of this section. tagsToAppend.append("\n\n").append(placeholder).append("\n"); willAppendAnything = true; - - // Не считаем как processed (пусть final pass заменит его на JSON-блок) - // но помечаем, чтобы ниже не пытаться добавлять повторно в рамках этой итерации - processedPlaceholders.add(normalizedPlaceholder); + if (processedPlaceholders != null) { + processedPlaceholders.add(normalizedPlaceholder); + } } if (!willAppendAnything) { @@ -3762,8 +3864,7 @@ public class MarketingAnalysisService { } result = result.substring(0, sectionEnd) + tagsToAppend + result.substring(sectionEnd); - logger.debug("addMissingChartsToSections: Added missing placeholder tags to section pattern: {}", - sectionPattern); + logger.debug("addMissingChartsToSections: Added missing placeholder tags to roman section {}", h.roman); } return result;