.
This commit is contained in:
@@ -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<SectionRange> 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;
|
||||
|
||||
+112
@@ -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<String, Object> buildChartsDataSeasonalityOnly() {
|
||||
Map<String, Object> 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<String>());
|
||||
|
||||
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<String> 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user