diff --git a/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java b/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java index 987ec81..b843432 100644 --- a/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java +++ b/src/main/java/kz/konturai/parser/service/MarketingAnalysisService.java @@ -1665,6 +1665,8 @@ public class MarketingAnalysisService { promptBuilder .append("На основе следующего JSON напиши РАЗДЕЛ I (Краткое резюме) маркетингового отчета.\n\n"); promptBuilder.append("ТРЕБОВАНИЯ:\n"); + promptBuilder.append( + "ВНИМАНИЕ: Ты ОБЯЗАН вставить строку плейсхолдера [[CHART_...]] (или [[TABLE_...]]) строго ВНУТРИ текста раздела, сразу после абзаца, который описывает эти данные. НЕ вставляй это в начало или конец. Это критично для верстки.\n\n"); promptBuilder.append("1. Объем: минимум 500-700 слов\n"); promptBuilder.append("2. Стиль: деловой, убедительный для владельца бизнеса\n"); promptBuilder.append("3. Глубина: аналитические инсайты с практическими выводами\n"); @@ -1716,6 +1718,8 @@ public class MarketingAnalysisService { promptBuilder .append("На основе следующего JSON напиши РАЗДЕЛ II (Анализ продукта) маркетингового отчета.\n\n"); promptBuilder.append("ТРЕБОВАНИЯ:\n"); + promptBuilder.append( + "ВНИМАНИЕ: Ты ОБЯЗАН вставить строку плейсхолдера [[CHART_...]] (или [[TABLE_...]]) строго ВНУТРИ текста раздела, сразу после абзаца, который описывает эти данные. НЕ вставляй это в начало или конец. Это критично для верстки.\n\n"); promptBuilder.append("1. Объем: минимум 500-700 слов\n"); promptBuilder.append("2. Стиль: деловой, убедительный для владельца бизнеса\n"); promptBuilder.append("3. Глубина: аналитические инсайты с практическими выводами\n"); @@ -1772,6 +1776,8 @@ public class MarketingAnalysisService { promptBuilder.append( "На основе следующего JSON напиши РАЗДЕЛ III (Анализ рынка и сезонности) маркетингового отчета.\n\n"); promptBuilder.append("ТРЕБОВАНИЯ:\n"); + promptBuilder.append( + "ВНИМАНИЕ: Ты ОБЯЗАН вставить строку плейсхолдера [[CHART_...]] (или [[TABLE_...]]) строго ВНУТРИ текста раздела, сразу после абзаца, который описывает эти данные. НЕ вставляй это в начало или конец. Это критично для верстки.\n\n"); promptBuilder.append("1. Объем: минимум 500-700 слов\n"); promptBuilder.append("2. Стиль: деловой, убедительный для владельца бизнеса\n"); promptBuilder.append("3. Глубина: аналитические инсайты с практическими выводами\n"); @@ -1848,6 +1854,8 @@ public class MarketingAnalysisService { promptBuilder.append( "На основе следующего JSON напиши РАЗДЕЛ IV (Анализ целевой аудитории) маркетингового отчета.\n\n"); promptBuilder.append("ТРЕБОВАНИЯ:\n"); + promptBuilder.append( + "ВНИМАНИЕ: Ты ОБЯЗАН вставить строку плейсхолдера [[CHART_...]] (или [[TABLE_...]]) строго ВНУТРИ текста раздела, сразу после абзаца, который описывает эти данные. НЕ вставляй это в начало или конец. Это критично для верстки.\n\n"); promptBuilder.append("1. Объем: минимум 500-700 слов\n"); promptBuilder.append("2. Стиль: деловой, убедительный для владельца бизнеса\n"); promptBuilder.append("3. Глубина: аналитические инсайты с практическими выводами\n"); @@ -1950,6 +1958,8 @@ public class MarketingAnalysisService { promptBuilder.append( "На основе следующего JSON напиши РАЗДЕЛ V (Анализ конкурентов) маркетингового отчета.\n\n"); promptBuilder.append("ТРЕБОВАНИЯ:\n"); + promptBuilder.append( + "ВНИМАНИЕ: Ты ОБЯЗАН вставить строку плейсхолдера [[CHART_...]] (или [[TABLE_...]]) строго ВНУТРИ текста раздела, сразу после абзаца, который описывает эти данные. НЕ вставляй это в начало или конец. Это критично для верстки.\n\n"); promptBuilder.append("1. Объем: минимум 500-700 слов\n"); promptBuilder.append("2. Стиль: деловой, убедительный для владельца бизнеса\n"); promptBuilder.append("3. Глубина: аналитические инсайты с практическими выводами\n"); @@ -2174,6 +2184,8 @@ public class MarketingAnalysisService { promptBuilder .append("На основе следующего JSON напиши РАЗДЕЛ VIII (Воронка спроса) маркетингового отчета.\n\n"); promptBuilder.append("ТРЕБОВАНИЯ:\n"); + promptBuilder.append( + "ВНИМАНИЕ: Ты ОБЯЗАН вставить строку плейсхолдера [[CHART_...]] (или [[TABLE_...]]) строго ВНУТРИ текста раздела, сразу после абзаца, который описывает эти данные. НЕ вставляй это в начало или конец. Это критично для верстки.\n\n"); promptBuilder.append("1. Объем: минимум 500-700 слов\n"); promptBuilder.append("2. Стиль: деловой, убедительный для владельца бизнеса\n"); promptBuilder.append("3. Глубина: аналитические инсайты с практическими выводами\n"); @@ -3807,13 +3819,25 @@ public class MarketingAnalysisService { romanToPlaceholders.put("VII", List.of("[[CHART_CHANNELS]]")); romanToPlaceholders.put("VIII", List.of("[[CHART_FUNNEL]]")); + // DEDUP RULE: + // If the model placed at least one placeholder in the text and we successfully + // replaced it (processedPlaceholders is non-empty), we must NOT append any + // missing placeholders. Appending at section ends can break layout and can + // cause "charts flying into TOC" issues. + if (processedPlaceholders != null && !processedPlaceholders.isEmpty()) { + return result; + } + // Find all section headings by Roman numerals. Allow optional markdown heading // prefix. // Example matches: // "## III. Анализ рынка и сезонности" // "V. Анализ конкурентов" + // IMPORTANT: + // - Only match real Markdown headings in the body (##, ###, ...). Do NOT match + // TOC list items like "- [III. ...](#section-iii)". java.util.regex.Pattern sectionHeader = java.util.regex.Pattern.compile( - "(?im)^(?:#{2,6}\\s*)?\\s*([IVX]{1,6})\\s*\\.\\s*.*$"); + "(?im)^(?!\\s*-\\s*\\[)\\s*#{2,6}\\s*([IVX]{1,6})\\s*\\.\\s*.*$"); java.util.regex.Matcher m = sectionHeader.matcher(result); class SectionRange { @@ -3828,6 +3852,11 @@ public class MarketingAnalysisService { List headers = new ArrayList<>(); while (m.find()) { + String fullLine = m.group(0); + if (fullLine != null && fullLine.trim().startsWith("- [")) { + // Safety net: ignore TOC list items even if regex is modified later + continue; + } String roman = m.group(1) != null ? m.group(1).toUpperCase() : null; if (roman == null || roman.isBlank()) { continue; diff --git a/src/test/java/kz/konturai/parser/service/MarketingAnalysisServiceChartPlacementTest.java b/src/test/java/kz/konturai/parser/service/MarketingAnalysisServiceChartPlacementTest.java new file mode 100644 index 0000000..d045d30 --- /dev/null +++ b/src/test/java/kz/konturai/parser/service/MarketingAnalysisServiceChartPlacementTest.java @@ -0,0 +1,112 @@ +package kz.konturai.parser.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +class MarketingAnalysisServiceChartPlacementTest { + + // Keep this test free of Spring/Mockito to avoid agent-attach requirements. + private final MarketingAnalysisService marketingAnalysisService = new MarketingAnalysisService(null, null, null, + null); + + @SuppressWarnings({ "unchecked", "rawtypes" }) + private Map buildPlaceholderMap() throws Exception { + Class chartMappingClass = Class.forName("kz.konturai.parser.service.MarketingAnalysisService$ChartMapping"); + Constructor ctor = chartMappingClass.getDeclaredConstructor(String.class, String.class); + ctor.setAccessible(true); + + Map placeholderMap = new HashMap(); + placeholderMap.put("[[CHART_SEASONALITY]]", ctor.newInstance("seasonality", "seasonality")); + placeholderMap.put("[[CHART_AGE_DISTRIBUTION]]", ctor.newInstance("audienceAge", "audienceAge")); + placeholderMap.put("[[CHART_GENDER_DISTRIBUTION]]", ctor.newInstance("audienceGender", "audienceGender")); + placeholderMap.put("[[CHART_MARKET_SHARE]]", ctor.newInstance("marketShareChart", "marketShareChart")); + 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_FUNNEL]]", ctor.newInstance("conversionFunnel", "funnel")); + return placeholderMap; + } + + private Map buildChartsDataSeasonalityOnly() { + Map chartsData = new HashMap<>(); + chartsData.put("seasonality", Map.of("labels", java.util.List.of("Jan"), "values", java.util.List.of(1))); + return chartsData; + } + + @Test + void addMissingChartsToSections_shouldIgnoreTocListItems_andUseRealMarkdownHeading() throws Exception { + String report = "" + + "## Оглавление\n" + + "- [III. Анализ рынка и сезонности](#section-iii)\n" + + "- [IV. Анализ целевой аудитории](#section-iv)\n" + + "\n" + + "## III. Анализ рынка и сезонности\n" + + "Тут есть текст про рынок.\n" + + "\n" + + "## IV. Анализ целевой аудитории\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, + buildChartsDataSeasonalityOnly(), + buildPlaceholderMap(), + new HashSet()); + + int tocEnd = out.indexOf("## III."); + assertTrue(tocEnd > 0, "Sanity check: section III should exist"); + String tocPart = out.substring(0, tocEnd); + assertFalse(tocPart.contains("[[CHART_SEASONALITY]]"), "Must not insert into TOC block"); + + int s3Start = out.indexOf("## III."); + int s4Start = out.indexOf("## IV."); + assertTrue(s3Start >= 0 && s4Start > s3Start, "Sanity check: III before IV"); + String section3Body = out.substring(s3Start, s4Start); + assertTrue(section3Body.contains("[[CHART_SEASONALITY]]"), "Must insert into section III body, not TOC"); + } + + @Test + void addMissingChartsToSections_whenAnyPlaceholderProcessed_shouldNotAppendAnything() throws Exception { + String report = "" + + "## III. Анализ рынка и сезонности\n" + + "Тут есть текст про рынок.\n" + + "\n" + + "## IV. Анализ целевой аудитории\n" + + "Тут аудитория.\n"; + + Method m = MarketingAnalysisService.class.getDeclaredMethod("addMissingChartsToSections", String.class, + Map.class, + Map.class, Set.class); + m.setAccessible(true); + + // Simulate: placeholder was already successfully replaced earlier in the + // pipeline + // (replaceChartPlaceholders adds normalized tokens to processedPlaceholders). + Set processed = new HashSet<>(); + processed.add("[[CHART_SEASONALITY]]".replaceAll("\\s+", "").toUpperCase(java.util.Locale.ROOT)); + + String out = (String) m.invoke( + marketingAnalysisService, + report, + buildChartsDataSeasonalityOnly(), + buildPlaceholderMap(), + processed); + + assertEquals(report, out, "If any placeholder was processed, must not append any missing placeholders"); + } +}