This commit is contained in:
root
2025-12-20 20:16:07 +05:00
parent dc2e25d13d
commit 0f8a59f13a
@@ -1283,6 +1283,77 @@ public class MarketingAnalysisService {
return cleaned;
}
/**
* Удаляет «пустые» фразы-заглушки про графики, если рядом нет плейсхолдера
* [[CHART_...]] / [[TABLE_...]] или ```json:<key> блока.
*
* Мотивация: модель может написать «Анализ данных на графике ниже
* показывает...», но забыть
* вставить плейсхолдер. В результате в тексте остаётся пустая ссылка на «график
* ниже».
*/
private String removeDanglingChartNarration(String text) {
if (text == null || text.trim().isEmpty()) {
return text;
}
String[] needles = {
"Анализ данных на графике ниже показывает",
"Из этих показателей следует, что"
};
String[] lines = text.split("\\r?\\n", -1);
List<String> 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<String, Object> chartsData,
Map<String, ChartMapping> placeholderMap, Set<String> processedPlaceholders) {
String result = text;
if (result == null || result.isBlank()) {
return result;
}
Map<String, List<String>> 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<String, List<String>> 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:<key> блок.
for (Map.Entry<String, List<String>> sectionEntry : sectionToPlaceholders.entrySet()) {
String sectionPattern = sectionEntry.getKey();
List<String> 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<SectionRange> 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<String> 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;