diff --git a/src/main/java/kz/konturai/parser/controller/ReportController.java b/src/main/java/kz/konturai/parser/controller/ReportController.java index 6c2f774..a00c39f 100644 --- a/src/main/java/kz/konturai/parser/controller/ReportController.java +++ b/src/main/java/kz/konturai/parser/controller/ReportController.java @@ -11,6 +11,7 @@ import kz.konturai.parser.repository.ReportHistoryRepository; import kz.konturai.parser.service.ReportGenerationService; import kz.konturai.parser.service.DeepResearchService; import kz.konturai.parser.service.ResearchPdfService; +import kz.konturai.parser.service.ReportSynthesisService; import kz.konturai.parser.service.MinIOService; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -38,17 +39,20 @@ public class ReportController { private final MinIOService minIOService; private final DeepResearchService deepResearchService; private final ResearchPdfService researchPdfService; + private final ReportSynthesisService reportSynthesisService; public ReportController(ReportGenerationService reportGenerationService, ReportHistoryRepository reportHistoryRepository, MinIOService minIOService, DeepResearchService deepResearchService, - ResearchPdfService researchPdfService) { + ResearchPdfService researchPdfService, + ReportSynthesisService reportSynthesisService) { this.reportGenerationService = reportGenerationService; this.reportHistoryRepository = reportHistoryRepository; this.minIOService = minIOService; this.deepResearchService = deepResearchService; this.researchPdfService = researchPdfService; + this.reportSynthesisService = reportSynthesisService; } @PostMapping("/generate") @@ -96,21 +100,30 @@ public class ReportController { // Предполагаемое время завершения LocalDateTime estimatedCompletion = LocalDateTime.now().plusMinutes(8); - // Запускаем асинхронный процесс: deep-research -> PDF -> сохранение + // Запускаем асинхронный процесс: deep-research -> synthesis via Ollama -> PDF + // -> сохранение deepResearchService.generateReportAsync(deepRequest) .publishOn(Schedulers.boundedElastic()) - .map(researchResponse -> { + .flatMap(researchResponse -> { if (researchResponse == null - || researchResponse.getMainContent() == null - || researchResponse.getMainContent().trim().isEmpty()) { - throw new RuntimeException("Empty research response content"); + || researchResponse.getLearnings() == null + || researchResponse.getLearnings().isEmpty()) { + return reactor.core.publisher.Mono.error( + new RuntimeException( + "Empty research response content")); } - System.out.println("researchResponse:" + researchResponse.getMainContent()); - System.out.println("researchResponse:" + researchResponse.getLearnings()); - System.out.println("researchResponse:" + researchResponse.getVisitedUrls()); - System.out.println("researchResponse:" + researchResponse.getStatus()); + return reportSynthesisService + .synthesizeReport(request.getQuery(), + researchResponse.getLearnings(), + request.getLang()) + .map(synthesizedMarkdown -> new Object[] { researchResponse, + synthesizedMarkdown }); + }) + .map(tuple -> { + DeepResearchResponse researchResponse = (DeepResearchResponse) tuple[0]; + String synthesizedMarkdown = (String) tuple[1]; byte[] pdfBytes = researchPdfService.generatePdfReport(request.getQuery(), - researchResponse); + synthesizedMarkdown, researchResponse.getVisitedUrls()); String filename = "research_report_" + System.currentTimeMillis() + ".pdf"; saveResearchReportToHistory(request, filename, pdfBytes, researchResponse); return true; diff --git a/src/main/java/kz/konturai/parser/dto/DeepResearchResponse.java b/src/main/java/kz/konturai/parser/dto/DeepResearchResponse.java index fb803a0..040610f 100644 --- a/src/main/java/kz/konturai/parser/dto/DeepResearchResponse.java +++ b/src/main/java/kz/konturai/parser/dto/DeepResearchResponse.java @@ -64,46 +64,28 @@ public class DeepResearchResponse { /** * Получить основной текст отчёта (report или answer + learnings) */ - // public String getMainContent() { - // StringBuilder contentBuilder = new StringBuilder(); - - // // Сначала добавляем answer, если есть - // if (answer != null && !answer.trim().isEmpty()) { - // contentBuilder.append(answer).append("\n\n"); - // } - - // // Затем добавляем learnings, если они есть - // if (learnings != null && !learnings.isEmpty()) { - // contentBuilder.append("### Подробные выводы:\n"); - // for (String learning : learnings) { - // contentBuilder.append("- ").append(learning).append("\n"); - // } - // contentBuilder.append("\n"); - // } - - // // Также можно добавить report, если вдруг deep-research начнет его - // возвращать - // // if (report != null && !report.trim().isEmpty()) { - // // contentBuilder.append(report).append("\n\n"); - // // } - - // return contentBuilder.toString().trim(); - // } - public String getMainContent() { StringBuilder contentBuilder = new StringBuilder(); - // Используем краткий 'answer' как вступительное предложение. + // Сначала добавляем answer, если есть if (answer != null && !answer.trim().isEmpty()) { - contentBuilder.append(answer).append(" "); // Добавляем пробел для соединения + contentBuilder.append(answer).append("\n\n"); } - // Объединяем все learnings в одну строку, разделяя их пробелами. + // Затем добавляем learnings, если они есть if (learnings != null && !learnings.isEmpty()) { - String singleArticleText = String.join(" ", learnings); - contentBuilder.append(singleArticleText); + contentBuilder.append("### Подробные выводы:\n"); + for (String learning : learnings) { + contentBuilder.append("- ").append(learning).append("\n"); + } + contentBuilder.append("\n"); } + // Также можно добавить report, если вдруг deep-research начнет его возвращать + // if (report != null && !report.trim().isEmpty()) { + // contentBuilder.append(report).append("\n\n"); + // } + return contentBuilder.toString().trim(); } diff --git a/src/main/java/kz/konturai/parser/service/ReportSynthesisService.java b/src/main/java/kz/konturai/parser/service/ReportSynthesisService.java new file mode 100644 index 0000000..8879fea --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/ReportSynthesisService.java @@ -0,0 +1,55 @@ +package kz.konturai.parser.service; + +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +@Service +public class ReportSynthesisService { + + private final OllamaAnalyticsService ollamaAnalyticsService; + + public ReportSynthesisService(OllamaAnalyticsService ollamaAnalyticsService) { + this.ollamaAnalyticsService = ollamaAnalyticsService; + } + + public Mono synthesizeReport(String originalQuery, List learnings, String lang) { + String aggregatedLearnings = formatLearnings(learnings); + String prompt = buildPrompt(originalQuery, aggregatedLearnings, lang); + return Mono.fromCallable(() -> ollamaAnalyticsService.generateWithInstruction(aggregatedLearnings, prompt)); + } + + private String formatLearnings(List learnings) { + if (learnings == null || learnings.isEmpty()) { + return "- (данные не были предоставлены)"; + } + List cleaned = learnings.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); + if (cleaned.isEmpty()) { + return "- (данные не были предоставлены)"; + } + return cleaned.stream().map(s -> "- " + s).collect(Collectors.joining("\n")); + } + + private String buildPrompt(String originalQuery, String learningsAsString, String lang) { + return "Ты — профессиональный AI-аналитик. Твоя задача — написать подробный и структурированный отчёт на основе предоставленных данных. Не добавляй никаких предисловий или комментариев от себя, просто верни готовый отчёт.\n\n" + + "### Тема исследования:\n\n" + + originalQuery + "\n\n" + + "### Собранные данные (тезисы):\n\n" + + learningsAsString + "\n\n" + + "### Твоя задача:\n\n" + + "1. **Язык:** Напиши отчёт полностью на " + (lang == null ? "русском" : lang) + + " языке.\n" + + "2. **Структура:** Отчёт должен иметь чёткую структуру: введение, основная часть с несколькими разделами и подразделами, и заключение.\n" + + "3. **Форматирование:** Используй Markdown. Для главных заголовков разделов используй `##`, для подзаголовков — `###`. Для списков используй `-`.\n" + + "4. **Стиль:** Преврати разрозненные тезисы в единую, связную и хорошо написанную статью. Не просто перечисляй факты, а анализируй и обобщай их.\n" + + "5. **Ограничение:** Используй только ту информацию, которая дана в собранных данных. Не придумывай ничего от себя.\n\n" + + "Начинай отчёт с главного заголовка."; + } +} diff --git a/src/main/java/kz/konturai/parser/service/ResearchPdfService.java b/src/main/java/kz/konturai/parser/service/ResearchPdfService.java index cfc77db..08a62b1 100644 --- a/src/main/java/kz/konturai/parser/service/ResearchPdfService.java +++ b/src/main/java/kz/konturai/parser/service/ResearchPdfService.java @@ -110,6 +110,30 @@ public class ResearchPdfService { } } + public byte[] generatePdfReport(String query, String synthesizedMarkdown, java.util.List visitedUrls) { + 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); + addSources(document, visitedUrls); + + document.close(); + return outputStream.toByteArray(); + } catch (Exception e) { + throw new RuntimeException("Failed to generate PDF report (synthesized)", e); + } + } + private void addTitlePage(Document document, String query) throws DocumentException { // Этот метод был в порядке, оставляем без изменений Paragraph title = new Paragraph(query, titleFont); @@ -191,6 +215,51 @@ public class ResearchPdfService { document.add(chapter); } + private void addMainReportFromMarkdown(Document document, String markdown) throws DocumentException { + String cleanedContent = cleanContent(markdown); + if (cleanedContent == null || cleanedContent.isEmpty()) { + cleanedContent = "## Отчёт не был сгенерирован.\n\nДанные для анализа отсутствуют."; + } + + document.newPage(); + + String[] lines = cleanedContent.split("\n"); + Chapter chapter = new Chapter(new Paragraph("Основной отчёт", headingFont), 1); + chapter.setNumberDepth(0); + + com.lowagie.text.List currentList = null; + + for (String line : lines) { + if (line.trim().isEmpty()) + continue; + if (!(line.startsWith("- ") || line.startsWith("* ")) && currentList != null) { + chapter.add(currentList); + currentList = null; + } + if (line.startsWith("# ")) { + chapter.add(new Paragraph(line.substring(2), headingFont)); + } else if (line.startsWith("## ")) { + chapter.add(new Paragraph(line.substring(3), subheadingFont)); + } else if (line.startsWith("- ") || line.startsWith("* ")) { + if (currentList == null) { + currentList = new com.lowagie.text.List(com.lowagie.text.List.UNORDERED); + currentList.setListSymbol(new Chunk("- ", normalFont)); + } + currentList.add(new ListItem(line.substring(2), normalFont)); + } else { + Paragraph para = new Paragraph(line, normalFont); + para.setSpacingAfter(10); + chapter.add(para); + } + } + + if (currentList != null) { + chapter.add(currentList); + } + + document.add(chapter); + } + private void addSources(Document document, List visitedUrls) throws DocumentException { document.newPage(); Paragraph sourcesTitle = new Paragraph("Источники", headingFont); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 8f51ae8..993697b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,3 +1,8 @@ +ollama.host=http://185.35.223.45:11434 +ollama.model=gemma3:1b +ollama.timeoutMs=180000 +ollama.connectTimeoutMs=10000 +ollama.responseTimeoutMs=180000 # MinIO configuration minio.endpoint=http://92.38.48.166:9000 minio.access-key=z9HhZTjzhVWqw3D20u7q @@ -46,9 +51,6 @@ logging.level.org.springframework.scheduling=DEBUG logging.level.org.springframework.data.mongodb=INFO logging.level.com.mongodb=WARN -# Ollama AI analytics configuration -ollama.host=http://185.35.223.45:11434 -ollama.model=gemma3:1b logging.level.kz.konturai.parser.service.OllamaAnalyticsService=INFO # Email Configuration diff --git a/Техническое Задание на реализацию AI-агента для синтеза отчётов с использованием Ollama.md b/Техническое Задание на реализацию AI-агента для синтеза отчётов с использованием Ollama.md new file mode 100644 index 0000000..13764a2 --- /dev/null +++ b/Техническое Задание на реализацию AI-агента для синтеза отчётов с использованием Ollama.md @@ -0,0 +1,135 @@ +Проект: Доработка модуля генерации отчётов в существующем Java/Spring Boot бэкенде. + +1. Общее описание + Целью является создание "Агента-синтезатора" внутри бэкенда. Этот агент будет использовать существующий сервис OllamaAnalyticsService для взаимодействия с локально развёрнутой языковой моделью Ollama. Задача агента — принять "сырые" тезисы (learnings) от сервиса deep-research и с помощью Ollama сгенерировать из них связный, структурированный и стилистически выверенный текстовый отчёт в формате Markdown. + +2. Требования к реализации + 2.1. Создание нового сервиса: ReportSynthesisService + +Необходимо создать новый Spring-сервис (@Service). + +Этот сервис будет зависеть от уже существующего OllamaAnalyticsService и должен инжектировать его (@Autowired). + +Сервис должен иметь один публичный метод: + +Java + +public Mono synthesizeReport(String originalQuery, List learnings, String lang) +Вход: + +originalQuery: Исходная тема исследования. + +learnings: Список "сырых" тезисов от deep-research. + +lang: Целевой язык отчёта. + +Выход: Mono, содержащий готовый отчёт в формате Markdown. + +2.2. Логика работы ReportSynthesisService + +Метод synthesizeReport получает на вход данные. + +Он объединяет список learnings в единый блок текста, где каждый тезис начинается с новой строки (например, с помощью String.join("\n- ", learnings)). + +Он формирует промпт (инструкцию) для модели Ollama (см. п. 2.3). + +Ключевой шаг: Он вызывает метод generateWithInstruction из вашего существующего OllamaAnalyticsService: + +Java + +// Преобразуем асинхронный вызов в Mono +Mono synthesizedReportMono = Mono.fromCallable(() -> +ollamaAnalyticsService.generateWithInstruction(aggregatedLearnings, prompt) +); +Метод возвращает synthesizedReportMono. + +2.3. Разработка промпта для Ollama + +Промпт — это сердце качественного результата. Он должен быть чётким и структурированным. Этот промпт будет реализован как форматированная строка внутри ReportSynthesisService. + +Пример промпта для Ollama (модели типа Llama3, Gemma): + +Ты — профессиональный AI-аналитик. Твоя задача — написать подробный и структурированный отчёт на основе предоставленных данных. Не добавляй никаких предисловий или комментариев от себя, просто верни готовый отчёт. + +### Тема исследования: + +${originalQuery} + +### Собранные данные (тезисы): + +- ${learnings_as_string} + +### Твоя задача: + +1. **Язык:** Напиши отчёт полностью на ${lang} языке. +2. **Структура:** Отчёт должен иметь чёткую структуру: введение, основная часть с несколькими разделами и подразделами, и заключение. +3. **Форматирование:** Используй Markdown. Для главных заголовков разделов используй `##`, для подзаголовков — `###`. Для списков используй `-`. +4. **Стиль:** Преврати разрозненные тезисы в единую, связную и хорошо написанную статью. Не просто перечисляй факты, а анализируй и обобщай их. +5. **Ограничение:** Используй только ту информацию, которая дана в собранных данных. Не придумывай ничего от себя. + +Начинай отчёт с главного заголовка. +2.4. Интеграция в существующий асинхронный процесс + +Необходимо модифицировать ваш существующий метод в контроллере, который запускает весь процесс. Вместо вызова OpenAI, теперь будет вызываться ваш новый ReportSynthesisService. + +Примерная схема интеграции (на основе вашего кода): + +Java + +// ... +// Инжектируем новый сервис +@Autowired +private ReportSynthesisService reportSynthesisService; + +// ... в вашем методе generateResearchReport +deepResearchService.generateReportAsync(deepRequest) +.publishOn(Schedulers.boundedElastic()) +.flatMap(researchResponse -> { // Используем flatMap для асинхронной цепочки +if (researchResponse == null || researchResponse.getLearnings() == null || researchResponse.getLearnings().isEmpty()) { +return Mono.error(new RuntimeException("Empty research response content")); +} +// НОВЫЙ ШАГ: Вызываем наш сервис синтеза с Ollama +return reportSynthesisService.synthesizeReport( +request.getQuery(), +researchResponse.getLearnings(), +request.getLang() +).map(synthesizedText -> new SynthesizedReport(researchResponse, synthesizedText)); // Объединяем результаты +}) +.map(synthesizedReport -> { +// Здесь мы используем synthesizedText для генерации PDF +// Этот код остаётся почти без изменений +byte[] pdfBytes = researchPdfService.generatePdfReport( +request.getQuery(), +synthesizedReport.getOriginalResponse(), +synthesizedReport.getSynthesizedMarkdown() // Передаем новый красивый текст от Ollama +); +String filename = "research*report*" + System.currentTimeMillis() + ".pdf"; +saveResearchReportToHistory(request, filename, pdfBytes, synthesizedReport.getOriginalResponse()); +return true; +}) +.doOnError(e -> { +System.err.println("Error in full research chain: " + e.getMessage()); +}) +.subscribe(); +// ... +Вам также понадобится немного изменить researchPdfService.generatePdfReport, чтобы он принимал новый синтезированный Markdown-текст в качестве основного контента. + +3. Конфигурация + Вся конфигурация для Ollama уже есть в вашем OllamaAnalyticsService. Новых настроек добавлять не нужно. + +Важная рекомендация: Убедитесь, что в вашем application.properties (или yml) для ollama.model указана достаточно мощная модель, способная следовать сложным инструкциям. Модель gemma3:1b слишком слабая для такой задачи. Рекомендуется использовать: + +llama3:8b-instruct (хороший баланс) + +gemma:7b-it + +mistral или mixtral + +Пример: + +Properties + +ollama.host=http://185.35.223.45:11434 +ollama.model=llama3:8b-instruct +ollama.timeoutMs=180000 # Увеличьте таймаут до 3 минут, т.к. генерация отчёта - долгая задача 4. Ожидаемый результат +После реализации бэкенд будет использовать ваш собственный, локально развёрнутый Ollama для создания высококачественных, структурированных PDF-отчётов, полностью контролируя весь процесс и не завися от внешних платных API для синтеза текста.