diff --git a/pom.xml b/pom.xml
index 25fd0ab..ef39d57 100644
--- a/pom.xml
+++ b/pom.xml
@@ -113,6 +113,13 @@
com.fasterxml.jackson.core
jackson-databind
+
+
+
+ org.apache.xmlgraphics
+ batik-transcoder
+ 1.17
+
diff --git a/src/main/java/kz/konturai/parser/controller/ReportController.java b/src/main/java/kz/konturai/parser/controller/ReportController.java
index a00c39f..2b901dd 100644
--- a/src/main/java/kz/konturai/parser/controller/ReportController.java
+++ b/src/main/java/kz/konturai/parser/controller/ReportController.java
@@ -1,6 +1,7 @@
package kz.konturai.parser.controller;
import kz.konturai.parser.dto.ReportGenerateRequest;
+import kz.konturai.parser.dto.FinalReportPayload;
import kz.konturai.parser.dto.ReportGenerationResponse;
import kz.konturai.parser.dto.ApiResponse;
import kz.konturai.parser.dto.ResearchReportRequest;
@@ -113,17 +114,17 @@ public class ReportController {
"Empty research response content"));
}
return reportSynthesisService
- .synthesizeReport(request.getQuery(),
+ .synthesizeReportAndCharts(request.getQuery(),
researchResponse.getLearnings(),
request.getLang())
- .map(synthesizedMarkdown -> new Object[] { researchResponse,
- synthesizedMarkdown });
+ .map(payload -> new Object[] { researchResponse, payload });
})
.map(tuple -> {
DeepResearchResponse researchResponse = (DeepResearchResponse) tuple[0];
- String synthesizedMarkdown = (String) tuple[1];
+ FinalReportPayload payload = (FinalReportPayload) tuple[1];
byte[] pdfBytes = researchPdfService.generatePdfReport(request.getQuery(),
- synthesizedMarkdown, researchResponse.getVisitedUrls());
+ payload.getMarkdownContent(), researchResponse.getVisitedUrls(),
+ payload.getChartImages());
String filename = "research_report_" + System.currentTimeMillis() + ".pdf";
saveResearchReportToHistory(request, filename, pdfBytes, researchResponse);
return true;
diff --git a/src/main/java/kz/konturai/parser/dto/FinalReportPayload.java b/src/main/java/kz/konturai/parser/dto/FinalReportPayload.java
new file mode 100644
index 0000000..7cf5bd9
--- /dev/null
+++ b/src/main/java/kz/konturai/parser/dto/FinalReportPayload.java
@@ -0,0 +1,34 @@
+package kz.konturai.parser.dto;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class FinalReportPayload {
+ public String markdownContent;
+ public List chartImages;
+
+ public FinalReportPayload() {
+ this.chartImages = new ArrayList<>();
+ }
+
+ public FinalReportPayload(String markdownContent, List chartImages) {
+ this.markdownContent = markdownContent;
+ this.chartImages = chartImages == null ? new ArrayList<>() : chartImages;
+ }
+
+ public String getMarkdownContent() {
+ return markdownContent;
+ }
+
+ public void setMarkdownContent(String markdownContent) {
+ this.markdownContent = markdownContent;
+ }
+
+ public List getChartImages() {
+ return chartImages;
+ }
+
+ public void setChartImages(List chartImages) {
+ this.chartImages = chartImages;
+ }
+}
diff --git a/src/main/java/kz/konturai/parser/service/ReportSynthesisService.java b/src/main/java/kz/konturai/parser/service/ReportSynthesisService.java
index 8879fea..a829159 100644
--- a/src/main/java/kz/konturai/parser/service/ReportSynthesisService.java
+++ b/src/main/java/kz/konturai/parser/service/ReportSynthesisService.java
@@ -1,8 +1,11 @@
package kz.konturai.parser.service;
+import kz.konturai.parser.dto.FinalReportPayload;
+import kz.konturai.parser.util.SvgToPngConverter;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
+import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
@@ -18,10 +21,37 @@ public class ReportSynthesisService {
public Mono synthesizeReport(String originalQuery, List learnings, String lang) {
String aggregatedLearnings = formatLearnings(learnings);
- String prompt = buildPrompt(originalQuery, aggregatedLearnings, lang);
+ String prompt = buildTextPrompt(originalQuery, aggregatedLearnings, lang);
return Mono.fromCallable(() -> ollamaAnalyticsService.generateWithInstruction(aggregatedLearnings, prompt));
}
+ public Mono synthesizeReportAndCharts(String originalQuery, List learnings,
+ String lang) {
+ String aggregatedLearnings = formatLearnings(learnings);
+
+ Mono textMono = Mono.fromCallable(
+ () -> ollamaAnalyticsService.generateWithInstruction(aggregatedLearnings,
+ buildTextPrompt(originalQuery, aggregatedLearnings, lang)));
+
+ Mono> chartsMono = Mono.fromCallable(() -> {
+ String json = ollamaAnalyticsService.generateWithInstruction(aggregatedLearnings,
+ buildChartDataPrompt(aggregatedLearnings));
+ if (json == null || json.trim().isEmpty() || json.trim().equals("{}")) {
+ return new ArrayList();
+ }
+ String svg = ollamaAnalyticsService.generateWithInstruction(json, buildSvgPrompt(json));
+ byte[] png = SvgToPngConverter.convertSvgToPng(svg);
+ List list = new ArrayList<>();
+ if (png != null && png.length > 0) {
+ list.add(png);
+ }
+ return list;
+ });
+
+ return Mono.zip(textMono, chartsMono)
+ .map(tuple -> new FinalReportPayload(tuple.getT1(), tuple.getT2()));
+ }
+
private String formatLearnings(List learnings) {
if (learnings == null || learnings.isEmpty()) {
return "- (данные не были предоставлены)";
@@ -37,7 +67,7 @@ public class ReportSynthesisService {
return cleaned.stream().map(s -> "- " + s).collect(Collectors.joining("\n"));
}
- private String buildPrompt(String originalQuery, String learningsAsString, String lang) {
+ private String buildTextPrompt(String originalQuery, String learningsAsString, String lang) {
return "Ты — профессиональный AI-аналитик. Твоя задача — написать подробный и структурированный отчёт на основе предоставленных данных. Не добавляй никаких предисловий или комментариев от себя, просто верни готовый отчёт.\n\n"
+ "### Тема исследования:\n\n"
+ originalQuery + "\n\n"
@@ -52,4 +82,14 @@ public class ReportSynthesisService {
+ "5. **Ограничение:** Используй только ту информацию, которая дана в собранных данных. Не придумывай ничего от себя.\n\n"
+ "Начинай отчёт с главного заголовка.";
}
+
+ private String buildChartDataPrompt(String learningsAsString) {
+ return "Ты — AI-аналитик данных. Внимательно проанализируй следующий текст. Если найдёшь в нём числовые данные, которые можно представить в виде простой столбчатой или круговой диаграммы (например, рост по годам, доли рынка, сравнение показателей), верни ТОЛЬКО один JSON-объект со структурой: {\\\"chartType\\\": \\\"bar\\\" или \\\"pie\\\", \\\"title\\\": \\\"Название диаграммы\\\", \\\"labels\\\": [\\\"Метка 1\\\", \\\"Метка 2\\\"], \\\"data\\\": [число1, число2]}. Если подходящих данных нет, верни пустой JSON-объект {}. Текст для анализа:\n---\n"
+ + learningsAsString + "\n---";
+ }
+
+ private String buildSvgPrompt(String json) {
+ return "Ты — эксперт по визуализации данных. На основе следующего JSON, сгенерируй полный и валидный SVG-код для диаграммы. SVG должен быть стильным и читаемым, с подписями на русском языке. Не добавляй никаких комментариев, верни ТОЛЬКО SVG-код. JSON с данными:\n---\n"
+ + json + "\n---";
+ }
}
diff --git a/src/main/java/kz/konturai/parser/service/ResearchPdfService.java b/src/main/java/kz/konturai/parser/service/ResearchPdfService.java
index f2134bd..8b7d00c 100644
--- a/src/main/java/kz/konturai/parser/service/ResearchPdfService.java
+++ b/src/main/java/kz/konturai/parser/service/ResearchPdfService.java
@@ -164,6 +164,64 @@ public class ResearchPdfService {
}
}
+ public byte[] generatePdfReport(String query, String synthesizedMarkdown, java.util.List visitedUrls,
+ java.util.List chartImages) {
+ try {
+ int contentLength = synthesizedMarkdown == null ? 0 : synthesizedMarkdown.length();
+ int urlCount = visitedUrls == null ? 0 : visitedUrls.size();
+ int charts = chartImages == null ? 0 : chartImages.size();
+ logger.info("PDF input (with charts): query='{}', contentLength={}, visitedUrlsCount={}, charts={}", query,
+ contentLength, urlCount, charts);
+ } catch (Exception logEx) {
+ }
+ try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
+ Document document = new Document(PageSize.A4, 36, 36, 36, 54);
+ PdfWriter writer = PdfWriter.getInstance(document, outputStream);
+
+ writer.setPageEvent(new PageFooter(footerFont));
+
+ document.addTitle(query);
+ document.addAuthor("AI Research Agent");
+ document.addSubject("Research Report");
+
+ document.open();
+
+ addTitlePage(document, query);
+ addMainReportFromMarkdown(document, synthesizedMarkdown);
+ addChartsSection(document, chartImages);
+ addSources(document, visitedUrls);
+
+ document.close();
+ return outputStream.toByteArray();
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to generate PDF report (with charts)", e);
+ }
+ }
+
+ private void addChartsSection(Document document, java.util.List chartImages) throws DocumentException {
+ if (chartImages == null || chartImages.isEmpty()) {
+ return;
+ }
+ document.newPage();
+ Paragraph title = new Paragraph("Визуализация данных", headingFont);
+ Chapter chapter = new Chapter(title, 3);
+ chapter.setNumberDepth(0);
+ for (byte[] imageData : chartImages) {
+ try {
+ Image chartImage = Image.getInstance(imageData);
+ float scaler = ((document.getPageSize().getWidth() - document.leftMargin() - document.rightMargin())
+ / chartImage.getWidth()) * 80;
+ chartImage.scalePercent(scaler);
+ chartImage.setAlignment(Element.ALIGN_CENTER);
+ chapter.add(new Paragraph(" "));
+ chapter.add(chartImage);
+ } catch (Exception e) {
+ System.err.println("Не удалось добавить изображение диаграммы в PDF: " + e.getMessage());
+ }
+ }
+ document.add(chapter);
+ }
+
private void addTitlePage(Document document, String query) throws DocumentException {
// Этот метод был в порядке, оставляем без изменений
Paragraph title = new Paragraph(query, titleFont);
diff --git a/src/main/java/kz/konturai/parser/util/SvgToPngConverter.java b/src/main/java/kz/konturai/parser/util/SvgToPngConverter.java
new file mode 100644
index 0000000..003ff5e
--- /dev/null
+++ b/src/main/java/kz/konturai/parser/util/SvgToPngConverter.java
@@ -0,0 +1,41 @@
+package kz.konturai.parser.util;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+
+public class SvgToPngConverter {
+
+ public static byte[] convertSvgToPng(String svg) {
+ if (svg == null || svg.isBlank()) {
+ return null;
+ }
+ try {
+ Class> pngTranscoderClass = Class.forName("org.apache.batik.transcoder.image.PNGTranscoder");
+ Object transcoder = pngTranscoderClass.getDeclaredConstructor().newInstance();
+
+ Class> transcoderInputClass = Class.forName("org.apache.batik.transcoder.TranscoderInput");
+ Class> transcoderOutputClass = Class.forName("org.apache.batik.transcoder.TranscoderOutput");
+
+ ByteArrayInputStream in = new ByteArrayInputStream(svg.getBytes(StandardCharsets.UTF_8));
+ Constructor> inputCtor = transcoderInputClass.getConstructor(java.io.InputStream.class);
+ Object input = inputCtor.newInstance(in);
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ Constructor> outputCtor = transcoderOutputClass.getConstructor(java.io.OutputStream.class);
+ Object output = outputCtor.newInstance(out);
+
+ Method transcode = pngTranscoderClass.getMethod("transcode", transcoderInputClass, transcoderOutputClass);
+ transcode.invoke(transcoder, input, output);
+
+ out.flush();
+ return out.toByteArray();
+ } catch (ClassNotFoundException e) {
+ return null;
+ } catch (Exception e) {
+ return null;
+ }
+ }
+}
diff --git a/Техническое Задание: Опциональное добавление диаграмм в PDF-отчёты.md b/Техническое Задание: Опциональное добавление диаграмм в PDF-отчёты.md
new file mode 100644
index 0000000..04e2c4d
--- /dev/null
+++ b/Техническое Задание: Опциональное добавление диаграмм в PDF-отчёты.md
@@ -0,0 +1,133 @@
+Техническое Задание: Опциональное добавление диаграмм в PDF-отчёты
+1. Общее описание
+Целью является улучшение существующих PDF-отчётов путём опционального добавления в них диаграмм и графиков. Основной процесс генерации текстового отчёта на основе learnings остаётся без изменений. Параллельно с этим, AI-агент будет пытаться найти в "сырых" данных (learnings) числовую информацию, подходящую для визуализации.
+
+Если такие данные найдены, агент сгенерирует диаграмму и вставит её в PDF. Если данных нет, будет сгенерирован обычный текстовый отчёт без диаграмм. Процесс не должен прерываться, если сгенерировать диаграмму невозможно.
+
+2. Обновлённая схема работы
+Процесс будет состоять из двух параллельных веток, которые затем объединяются:
+
+Сбор данных: Бэкенд вызывает deep-research API и получает learnings и visitedUrls.
+
+Запускаются два асинхронных вызова к Ollama:
+
+Ветка A (Синтез текста - основная): Все learnings отправляются в Ollama с промптом на написание красивого, структурированного текстового отчёта в формате Markdown. (Этот шаг остаётся без изменений).
+
+Ветка B (Поиск данных для диаграммы - опциональная): Все learnings отправляются в Ollama с новым, специальным промптом для поиска числовых данных и возврата их в виде JSON.
+
+Обработка Ветки B (если успешно):
+
+Если Ollama вернул валидный JSON с данными, делается третий вызов к Ollama с промптом на генерацию SVG-кода этой диаграммы.
+
+
+Shutterstock
+* Полученный SVG-код конвертируется в изображение (PNG) на стороне Java.
+Финальная сборка PDF:
+
+ResearchPdfService получает обязательный текстовый отчёт из Ветки А, опциональное изображение диаграммы из Ветки B и список visitedUrls.
+
+Он собирает всё это в единый PDF-документ и возвращает пользователю.
+
+3. Требования к реализации
+3.1. Модификация сервиса синтеза (например, ReportSynthesisService)
+
+Этот сервис теперь будет оркестрировать несколько асинхронных вызовов.
+
+Основной метод synthesizeReportAndCharts будет возвращать объект, содержащий и текст, и изображения, например Mono.
+
+Java
+
+class FinalReportPayload {
+ String markdownContent;
+ List chartImages;
+ // ...
+}
+Внутри этого метода будут асинхронно запускаться два вызова к Ollama:
+
+generateTextReport(learnings) - для получения текста.
+
+generateChartData(learnings) - для получения JSON для диаграммы.
+
+3.2. Промпт-инжиниринг для диаграмм (Ветка B)
+
+Шаг 1: Извлечение данных в JSON
+
+Задача: Найти в тексте данные для визуализации.
+
+Промпт:
+
+"Ты — AI-аналитик данных. Внимательно проанализируй следующий текст. Если найдёшь в нём числовые данные, которые можно представить в виде простой столбчатой или круговой диаграммы (например, рост по годам, доли рынка, сравнение показателей), верни ТОЛЬКО один JSON-объект со структурой: {\"chartType\": \"bar\" или \"pie\", \"title\": \"Название диаграммы\", \"labels\": [\"Метка 1\", \"Метка 2\"], \"data\": [число1, число2]}. Если подходящих данных нет, верни пустой JSON-объект {}. Текст для анализа:\n---\n${learnings_as_string}\n---"
+
+Шаг 2: Генерация SVG из JSON
+
+Задача: Превратить JSON с данными в SVG-код.
+
+Промпт:
+
+"Ты — эксперт по визуализации данных. На основе следующего JSON, сгенерируй полный и валидный SVG-код для диаграммы. SVG должен быть стильным и читаемым, с подписями на русском языке. Не добавляй никаких комментариев, верни ТОЛЬКО SVG-код. JSON с данными:\n---\n${json_from_previous_step}\n---"
+
+3.3. Конвертация SVG в изображение
+
+Этот модуль остаётся без изменений. Используется библиотека Apache Batik для преобразования SVG-строки в byte[] PNG-изображения.
+
+Зависимость в pom.xml:
+
+XML
+
+
+ org.apache.xmlgraphics
+ batik-transcoder
+ 1.17
+
+Вспомогательный класс-конвертер остаётся тем же.
+
+3.4. Модификация PDF-сервиса (ResearchPdfService)
+
+Сервис должен быть готов к тому, что диаграмм может и не быть.
+
+Измените метод generatePdfReport, чтобы он принимал список изображений, который может быть пустым:
+
+Java
+
+public byte[] generatePdfReport(String query, String markdownContent, List visitedUrls, List chartImages)
+Внутри метода добавьте проверку: если список chartImages не пустой, вставляйте изображения в документ.
+
+Java
+
+// ... после вставки основного текста отчёта ...
+
+if (chartImages != null && !chartImages.isEmpty()) {
+ document.newPage();
+ document.add(new Paragraph("Визуализация данных", headingFont));
+
+ for (byte[] imageData : chartImages) {
+ try {
+ Image chartImage = Image.getInstance(imageData);
+ // Масштабируем, чтобы поместилось на страницу
+ float scaler = ((document.getPageSize().getWidth() - document.leftMargin() - document.rightMargin()) / chartImage.getWidth()) * 80; // 80% ширины
+ chartImage.scalePercent(scaler);
+ chartImage.setAlignment(Element.ALIGN_CENTER);
+
+ document.add(new Paragraph(" ")); // Отступ
+ document.add(chartImage);
+ } catch (Exception e) {
+ // Логируем ошибку, но не прерываем генерацию PDF
+ System.err.println("Не удалось добавить изображение диаграммы в PDF: " + e.getMessage());
+ }
+ }
+}
+4. План действий
+Добавить зависимость Apache Batik в pom.xml.
+
+Реализовать асинхронную логику в ReportSynthesisService, которая параллельно запрашивает у Ollama:
+
+Основной текст отчёта.
+
+JSON с данными для диаграммы.
+
+Добавить в тот же сервис условную логику: если JSON получен, сделать ещё один запрос к Ollama за SVG-кодом.
+
+Реализовать SVG-конвертер (или добавить его как util-класс).
+
+Обновить ResearchPdfService, чтобы он мог принимать и вставлять список изображений, если он не пуст.
+