From 40626e9a14a1eb4039fd00e1cf9ddc7d3b105638 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 6 Oct 2025 17:38:10 +0500 Subject: [PATCH] svg --- pom.xml | 7 + .../parser/controller/ReportController.java | 11 +- .../parser/dto/FinalReportPayload.java | 34 +++++ .../service/ReportSynthesisService.java | 44 +++++- .../parser/service/ResearchPdfService.java | 58 ++++++++ .../parser/util/SvgToPngConverter.java | 41 ++++++ ...альное добавление диаграмм в PDF-отчёты.md | 133 ++++++++++++++++++ 7 files changed, 321 insertions(+), 7 deletions(-) create mode 100644 src/main/java/kz/konturai/parser/dto/FinalReportPayload.java create mode 100644 src/main/java/kz/konturai/parser/util/SvgToPngConverter.java create mode 100644 Техническое Задание: Опциональное добавление диаграмм в PDF-отчёты.md 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, чтобы он мог принимать и вставлять список изображений, если он не пуст. +