svg
This commit is contained in:
@@ -113,6 +113,13 @@
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Apache Batik for SVG -> PNG conversion -->
|
||||
<dependency>
|
||||
<groupId>org.apache.xmlgraphics</groupId>
|
||||
<artifactId>batik-transcoder</artifactId>
|
||||
<version>1.17</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<byte[]> chartImages;
|
||||
|
||||
public FinalReportPayload() {
|
||||
this.chartImages = new ArrayList<>();
|
||||
}
|
||||
|
||||
public FinalReportPayload(String markdownContent, List<byte[]> 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<byte[]> getChartImages() {
|
||||
return chartImages;
|
||||
}
|
||||
|
||||
public void setChartImages(List<byte[]> chartImages) {
|
||||
this.chartImages = chartImages;
|
||||
}
|
||||
}
|
||||
@@ -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<String> synthesizeReport(String originalQuery, List<String> 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<FinalReportPayload> synthesizeReportAndCharts(String originalQuery, List<String> learnings,
|
||||
String lang) {
|
||||
String aggregatedLearnings = formatLearnings(learnings);
|
||||
|
||||
Mono<String> textMono = Mono.fromCallable(
|
||||
() -> ollamaAnalyticsService.generateWithInstruction(aggregatedLearnings,
|
||||
buildTextPrompt(originalQuery, aggregatedLearnings, lang)));
|
||||
|
||||
Mono<List<byte[]>> chartsMono = Mono.fromCallable(() -> {
|
||||
String json = ollamaAnalyticsService.generateWithInstruction(aggregatedLearnings,
|
||||
buildChartDataPrompt(aggregatedLearnings));
|
||||
if (json == null || json.trim().isEmpty() || json.trim().equals("{}")) {
|
||||
return new ArrayList<byte[]>();
|
||||
}
|
||||
String svg = ollamaAnalyticsService.generateWithInstruction(json, buildSvgPrompt(json));
|
||||
byte[] png = SvgToPngConverter.convertSvgToPng(svg);
|
||||
List<byte[]> 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<String> 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---";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +164,64 @@ public class ResearchPdfService {
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] generatePdfReport(String query, String synthesizedMarkdown, java.util.List<String> visitedUrls,
|
||||
java.util.List<byte[]> 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<byte[]> 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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<FinalReportPayload>.
|
||||
|
||||
Java
|
||||
|
||||
class FinalReportPayload {
|
||||
String markdownContent;
|
||||
List<byte[]> 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
|
||||
|
||||
<dependency>
|
||||
<groupId>org.apache.xmlgraphics</groupId>
|
||||
<artifactId>batik-transcoder</artifactId>
|
||||
<version>1.17</version>
|
||||
</dependency>
|
||||
Вспомогательный класс-конвертер остаётся тем же.
|
||||
|
||||
3.4. Модификация PDF-сервиса (ResearchPdfService)
|
||||
|
||||
Сервис должен быть готов к тому, что диаграмм может и не быть.
|
||||
|
||||
Измените метод generatePdfReport, чтобы он принимал список изображений, который может быть пустым:
|
||||
|
||||
Java
|
||||
|
||||
public byte[] generatePdfReport(String query, String markdownContent, List<String> visitedUrls, List<byte[]> 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, чтобы он мог принимать и вставлять список изображений, если он не пуст.
|
||||
|
||||
Reference in New Issue
Block a user