diff --git a/pom.xml b/pom.xml index 0577d56..80857b1 100644 --- a/pom.xml +++ b/pom.xml @@ -73,6 +73,33 @@ spring-boot-starter-test test + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.apache.poi + poi-ooxml + 5.2.5 + + + + + com.github.librepdf + openpdf + 1.3.40 + + + + + org.jfree + jfreechart + 1.5.4 + diff --git a/src/main/java/kz/konturai/parser/controller/ReportController.java b/src/main/java/kz/konturai/parser/controller/ReportController.java new file mode 100644 index 0000000..f62b1c5 --- /dev/null +++ b/src/main/java/kz/konturai/parser/controller/ReportController.java @@ -0,0 +1,79 @@ +package kz.konturai.parser.controller; + +import kz.konturai.parser.dto.ReportGenerateRequest; +import kz.konturai.parser.model.ReportHistory; +import kz.konturai.parser.repository.ReportHistoryRepository; +import kz.konturai.parser.service.ReportGenerationService; +import kz.konturai.parser.service.ReportGenerationService.ReportBinary; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; + +@RestController +@RequestMapping("/api/parser/report") +public class ReportController { + + private final ReportGenerationService reportGenerationService; + private final ReportHistoryRepository reportHistoryRepository; + + public ReportController(ReportGenerationService reportGenerationService, + ReportHistoryRepository reportHistoryRepository) { + this.reportGenerationService = reportGenerationService; + this.reportHistoryRepository = reportHistoryRepository; + } + + @PostMapping("/generate") + public ResponseEntity generate(@Validated @RequestBody ReportGenerateRequest request) { + ReportBinary rb = reportGenerationService.generate(request); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + rb.getFilename() + "\"") + .contentType(rb.getMediaType()) + .body(rb.getBytes()); + } + + @GetMapping("/history") + public ResponseEntity> history(@RequestParam(name = "page", defaultValue = "0") int page, + @RequestParam(name = "size", defaultValue = "20") int size, + @RequestParam(name = "sort", defaultValue = "createdAt,desc") String sort) { + String[] parts = sort.split(","); + String sortField = parts.length > 0 ? parts[0] : "createdAt"; + Sort.Direction dir = parts.length > 1 && parts[1].equalsIgnoreCase("asc") ? Sort.Direction.ASC + : Sort.Direction.DESC; + Pageable pageable = PageRequest.of(page, size, Sort.by(dir, sortField)); + Page res = reportHistoryRepository.findAll(pageable); + return ResponseEntity.ok(res); + } + + @GetMapping("/history/{id}") + public ResponseEntity download(@PathVariable("id") String id) { + Optional opt = reportHistoryRepository.findById(id); + if (opt.isEmpty()) { + return ResponseEntity.notFound().build(); + } + ReportHistory rh = opt.get(); + Path path = Path.of(rh.getFilePath()); + try { + byte[] bytes = Files.readAllBytes(path); + MediaType mt = rh.getFormat() != null && rh.getFormat().equalsIgnoreCase("DOCX") + ? MediaType + .parseMediaType("application/vnd.openxmlformats-officedocument.wordprocessingml.document") + : MediaType.APPLICATION_PDF; + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + rh.getFilename() + "\"") + .contentType(mt) + .body(bytes); + } catch (Exception e) { + return ResponseEntity.internalServerError().build(); + } + } +} diff --git a/src/main/java/kz/konturai/parser/dto/ReportGenerateRequest.java b/src/main/java/kz/konturai/parser/dto/ReportGenerateRequest.java new file mode 100644 index 0000000..bbf54f0 --- /dev/null +++ b/src/main/java/kz/konturai/parser/dto/ReportGenerateRequest.java @@ -0,0 +1,61 @@ +package kz.konturai.parser.dto; + +import java.time.LocalDateTime; + +public class ReportGenerateRequest { + + private String reportTitle; + private String authorName; + private String companyName; + private LocalDateTime startDate; + private LocalDateTime endDate; + private String format; // PDF or DOCX + + public String getReportTitle() { + return reportTitle; + } + + public void setReportTitle(String reportTitle) { + this.reportTitle = reportTitle; + } + + public String getAuthorName() { + return authorName; + } + + public void setAuthorName(String authorName) { + this.authorName = authorName; + } + + public String getCompanyName() { + return companyName; + } + + public void setCompanyName(String companyName) { + this.companyName = companyName; + } + + public LocalDateTime getStartDate() { + return startDate; + } + + public void setStartDate(LocalDateTime startDate) { + this.startDate = startDate; + } + + public LocalDateTime getEndDate() { + return endDate; + } + + public void setEndDate(LocalDateTime endDate) { + this.endDate = endDate; + } + + public String getFormat() { + return format; + } + + public void setFormat(String format) { + this.format = format; + } +} diff --git a/src/main/java/kz/konturai/parser/model/ReportHistory.java b/src/main/java/kz/konturai/parser/model/ReportHistory.java new file mode 100644 index 0000000..dca03dd --- /dev/null +++ b/src/main/java/kz/konturai/parser/model/ReportHistory.java @@ -0,0 +1,132 @@ +package kz.konturai.parser.model; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +import java.time.LocalDateTime; + +@Document(collection = "report_history") +public class ReportHistory { + + @Id + private String id; + + @Field("report_title") + private String reportTitle; + + @Field("author_name") + private String authorName; + + @Field("company_name") + private String companyName; + + @Field("start_date") + private LocalDateTime startDate; + + @Field("end_date") + private LocalDateTime endDate; + + @Field("format") + private String format; // PDF or DOCX + + @Field("filename") + private String filename; + + @Field("file_path") + private String filePath; + + @Field("file_size") + private long fileSize; + + @Field("created_at") + private LocalDateTime createdAt = LocalDateTime.now(); + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getReportTitle() { + return reportTitle; + } + + public void setReportTitle(String reportTitle) { + this.reportTitle = reportTitle; + } + + public String getAuthorName() { + return authorName; + } + + public void setAuthorName(String authorName) { + this.authorName = authorName; + } + + public String getCompanyName() { + return companyName; + } + + public void setCompanyName(String companyName) { + this.companyName = companyName; + } + + public LocalDateTime getStartDate() { + return startDate; + } + + public void setStartDate(LocalDateTime startDate) { + this.startDate = startDate; + } + + public LocalDateTime getEndDate() { + return endDate; + } + + public void setEndDate(LocalDateTime endDate) { + this.endDate = endDate; + } + + public String getFormat() { + return format; + } + + public void setFormat(String format) { + this.format = format; + } + + public String getFilename() { + return filename; + } + + public void setFilename(String filename) { + this.filename = filename; + } + + public String getFilePath() { + return filePath; + } + + public void setFilePath(String filePath) { + this.filePath = filePath; + } + + public long getFileSize() { + return fileSize; + } + + public void setFileSize(long fileSize) { + this.fileSize = fileSize; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/src/main/java/kz/konturai/parser/repository/ReportHistoryRepository.java b/src/main/java/kz/konturai/parser/repository/ReportHistoryRepository.java new file mode 100644 index 0000000..4fccec3 --- /dev/null +++ b/src/main/java/kz/konturai/parser/repository/ReportHistoryRepository.java @@ -0,0 +1,12 @@ +package kz.konturai.parser.repository; + +import kz.konturai.parser.model.ReportHistory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ReportHistoryRepository extends MongoRepository { + Page findAllByOrderByCreatedAtDesc(Pageable pageable); +} diff --git a/src/main/java/kz/konturai/parser/service/OllamaAnalyticsService.java b/src/main/java/kz/konturai/parser/service/OllamaAnalyticsService.java index d4c8c94..398066d 100644 --- a/src/main/java/kz/konturai/parser/service/OllamaAnalyticsService.java +++ b/src/main/java/kz/konturai/parser/service/OllamaAnalyticsService.java @@ -116,4 +116,38 @@ public class OllamaAnalyticsService { return null; } } + + public String generateWithInstruction(String text, String instruction) { + String prompt = instruction + "\n\n" + text; + Map requestBody = new HashMap<>(); + requestBody.put("model", modelName); + requestBody.put("prompt", prompt); + requestBody.put("stream", false); + + try { + Map response = this.webClient.post() + .uri("/api/generate") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .bodyValue(requestBody) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() { + }) + .onErrorResume(err -> { + logger.warn("Ollama request failed: {}", err.getMessage()); + return Mono.empty(); + }) + .block(); + + if (response == null) { + return null; + } + + Object res = response.get("response"); + return res == null ? null : String.valueOf(res); + } catch (Exception e) { + logger.warn("Error calling Ollama generate (generic): {}", e.getMessage()); + return null; + } + } } diff --git a/src/main/java/kz/konturai/parser/service/ReportGenerationService.java b/src/main/java/kz/konturai/parser/service/ReportGenerationService.java new file mode 100644 index 0000000..404650f --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/ReportGenerationService.java @@ -0,0 +1,374 @@ +package kz.konturai.parser.service; + +import kz.konturai.parser.dto.ReportGenerateRequest; +import kz.konturai.parser.model.MarketItem; +import kz.konturai.parser.repository.MarketItemRepository; +import kz.konturai.parser.model.ReportHistory; +import kz.konturai.parser.repository.ReportHistoryRepository; +import org.springframework.beans.factory.annotation.Value; +import org.jfree.chart.ChartFactory; +import org.jfree.chart.JFreeChart; +import org.jfree.chart.encoders.EncoderUtil; +import org.jfree.chart.encoders.ImageFormat; +import org.jfree.data.general.DefaultPieDataset; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.apache.poi.xwpf.usermodel.XWPFRun; +import org.apache.poi.util.Units; +import com.lowagie.text.Document; +import com.lowagie.text.Image; +import com.lowagie.text.Paragraph; +import com.lowagie.text.pdf.PdfWriter; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class ReportGenerationService { + + public static class ReportBinary { + private final String filename; + private final MediaType mediaType; + private final byte[] bytes; + + public ReportBinary(String filename, MediaType mediaType, byte[] bytes) { + this.filename = filename; + this.mediaType = mediaType; + this.bytes = bytes; + } + + public String getFilename() { + return filename; + } + + public MediaType getMediaType() { + return mediaType; + } + + public byte[] getBytes() { + return bytes; + } + } + + private final MarketItemRepository marketItemRepository; + private final OllamaAnalyticsService ollamaAnalyticsService; + private final ReportHistoryRepository reportHistoryRepository; + private final String storagePath; + + public ReportGenerationService(MarketItemRepository marketItemRepository, + OllamaAnalyticsService ollamaAnalyticsService, + ReportHistoryRepository reportHistoryRepository, + @Value("${reports.storage.path:/data/reports}") String storagePath) { + this.marketItemRepository = marketItemRepository; + this.ollamaAnalyticsService = ollamaAnalyticsService; + this.reportHistoryRepository = reportHistoryRepository; + this.storagePath = storagePath; + } + + public ReportBinary generate(ReportGenerateRequest req) { + LocalDateTime start = req.getStartDate(); + LocalDateTime end = req.getEndDate(); + + List items = marketItemRepository + .findByPublishedAtBetween(start, end, org.springframework.data.domain.Pageable.unpaged()) + .getContent(); + + String annotation = buildAnnotation(items); + + ReportBinary rb; + if ("DOCX".equalsIgnoreCase(req.getFormat())) { + byte[] docx = buildDocx(req, items, annotation); + String filename = "report_" + end.toLocalDate().toString() + "-" + System.currentTimeMillis() + ".docx"; + rb = new ReportBinary(filename, + MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + docx); + } else { + byte[] pdf = buildPdf(req, items, annotation); + String filename = "report_" + end.toLocalDate().toString() + "-" + System.currentTimeMillis() + ".pdf"; + rb = new ReportBinary(filename, MediaType.APPLICATION_PDF, pdf); + } + persistReport(req, rb); + return rb; + } + + private void persistReport(ReportGenerateRequest req, ReportBinary rb) { + try { + java.nio.file.Path dir = java.nio.file.Paths.get(storagePath); + java.nio.file.Files.createDirectories(dir); + java.nio.file.Path file = dir.resolve(rb.getFilename()); + if (rb.getBytes() != null) { + java.nio.file.Files.write(file, rb.getBytes()); + } + + ReportHistory rh = new ReportHistory(); + rh.setReportTitle(req.getReportTitle()); + rh.setAuthorName(req.getAuthorName()); + rh.setCompanyName(req.getCompanyName()); + rh.setStartDate(req.getStartDate()); + rh.setEndDate(req.getEndDate()); + rh.setFormat(req.getFormat()); + rh.setFilename(rb.getFilename()); + rh.setFilePath(file.toString()); + rh.setFileSize(rb.getBytes() == null ? 0 : rb.getBytes().length); + rh.setCreatedAt(java.time.LocalDateTime.now()); + reportHistoryRepository.save(rh); + } catch (Exception ignored) { + } + } + + private String buildAnnotation(List items) { + String combined = items.stream() + .map(i -> i.getAnalytics() != null ? i.getAnalytics().getSummary() : null) + .filter(Objects::nonNull) + .collect(Collectors.joining("\n\n")); + if (combined.isBlank()) { + return ""; + } + String instruction = "На основе этих кратких сводок новостей напиши общую аннотацию на 2-3 абзаца, выделяя ключевые тренды и события."; + String res = ollamaAnalyticsService.generateWithInstruction(combined, instruction); + return res == null ? "" : res; + } + + private byte[] buildDocx(ReportGenerateRequest req, List items, String annotation) { + try (XWPFDocument doc = new XWPFDocument(); ByteArrayOutputStream out = new ByteArrayOutputStream()) { + DateTimeFormatter dt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + + // Title page + addHeading(doc, req.getReportTitle()); + addParagraph(doc, "Автор: " + req.getAuthorName()); + addParagraph(doc, "Компания: " + req.getCompanyName()); + addParagraph(doc, "Дата: " + LocalDateTime.now().format(dt)); + + // TOC placeholder + addHeading(doc, "Содержание"); + addParagraph(doc, + "(Автоматически) Аннотация, Введение, Основная часть, Рекомендации, Список литературы, Приложения"); + + // Annotation + addHeading(doc, "Краткое содержание"); + addParagraph(doc, annotation.isBlank() ? "(недоступно)" : annotation); + + // Intro + addHeading(doc, "Введение"); + addParagraph(doc, String.format( + "Целью данного отчёта является анализ новостного фона в сфере маркетинга и бизнеса за период с %s по %s. Были проанализированы публикации из ключевых источников для выявления основных трендов.", + req.getStartDate().format(dt), req.getEndDate().format(dt))); + + // Main + addHeading(doc, "Основная часть"); + addSubheading(doc, "Ключевые публикации"); + List top = items.stream() + .sorted(Comparator + .comparing(MarketItem::getPublishedAt, Comparator.nullsLast(Comparator.naturalOrder())) + .reversed()) + .limit(10) + .toList(); + for (MarketItem mi : top) { + addParagraph(doc, String.format("— %s | %s | %s", safe(mi.getTitle()), safe(mi.getSourceName()), + mi.getPublishedAt() == null ? "" : mi.getPublishedAt().format(dt))); + if (mi.getAnalytics() != null && mi.getAnalytics().getSummary() != null) { + addParagraph(doc, mi.getAnalytics().getSummary()); + } + } + + addSubheading(doc, "Анализ тональности"); + Map sentiment = countSentiment(items); + byte[] piePng = buildSentimentChart(sentiment); + if (piePng != null) { + XWPFParagraph p = doc.createParagraph(); + XWPFRun r = p.createRun(); + r.addPicture(new ByteArrayInputStream(piePng), XWPFDocument.PICTURE_TYPE_PNG, "sentiment.png", + Units.toEMU(480), Units.toEMU(320)); + } + + addSubheading(doc, "Упоминаемые компании и персоны"); + Map companies = collectEntityFreq(items, "companies"); + Map persons = collectEntityFreq(items, "persons"); + addParagraph(doc, "Компании: " + formatTop(companies)); + addParagraph(doc, "Персоны: " + formatTop(persons)); + + // Recommendations + addHeading(doc, "Рекомендации"); + addParagraph(doc, "Рассмотрите возможность усиления присутствия в медиа по ключевым темам отчёта."); + + // Bibliography + addHeading(doc, "Список литературы"); + for (MarketItem mi : items) { + addParagraph(doc, String.format("%s. Источник: %s. URL: %s", + safe(mi.getTitle()), safe(mi.getSourceName()), safe(mi.getUrl()))); + } + + // Appendix + addHeading(doc, "Приложения"); + addParagraph(doc, "Полная таблица статей доступна в базе данных за период отчёта."); + + doc.write(out); + return out.toByteArray(); + } catch (Exception e) { + return new byte[0]; + } + } + + private byte[] buildPdf(ReportGenerateRequest req, List items, String annotation) { + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + Document pdf = new Document(); + PdfWriter.getInstance(pdf, out); + pdf.open(); + DateTimeFormatter dt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + + pdf.add(new Paragraph(req.getReportTitle())); + pdf.add(new Paragraph("Автор: " + req.getAuthorName())); + pdf.add(new Paragraph("Компания: " + req.getCompanyName())); + pdf.add(new Paragraph("Дата: " + LocalDateTime.now().format(dt))); + + pdf.add(new Paragraph( + "\nСодержание: Аннотация, Введение, Основная часть, Рекомендации, Список литературы, Приложения\n")); + + pdf.add(new Paragraph("Краткое содержание")); + pdf.add(new Paragraph(annotation.isBlank() ? "(недоступно)" : annotation)); + + pdf.add(new Paragraph("Введение")); + pdf.add(new Paragraph(String.format( + "Целью данного отчёта является анализ новостного фона в сфере маркетинга и бизнеса за период с %s по %s. Были проанализированы публикации из ключевых источников для выявления основных трендов.", + req.getStartDate().format(dt), req.getEndDate().format(dt)))); + + pdf.add(new Paragraph("Основная часть")); + pdf.add(new Paragraph("Ключевые публикации")); + List top = items.stream() + .sorted(Comparator + .comparing(MarketItem::getPublishedAt, Comparator.nullsLast(Comparator.naturalOrder())) + .reversed()) + .limit(10) + .toList(); + for (MarketItem mi : top) { + pdf.add(new Paragraph(String.format("— %s | %s | %s", safe(mi.getTitle()), safe(mi.getSourceName()), + mi.getPublishedAt() == null ? "" : mi.getPublishedAt().format(dt)))); + if (mi.getAnalytics() != null && mi.getAnalytics().getSummary() != null) { + pdf.add(new Paragraph(mi.getAnalytics().getSummary())); + } + } + + pdf.add(new Paragraph("Анализ тональности")); + Map sentiment = countSentiment(items); + byte[] piePng = buildSentimentChart(sentiment); + if (piePng != null) { + Image img = Image.getInstance(piePng); + img.scaleToFit(480, 320); + pdf.add(img); + } + + Map companies = collectEntityFreq(items, "companies"); + Map persons = collectEntityFreq(items, "persons"); + pdf.add(new Paragraph("Упоминаемые компании и персоны")); + pdf.add(new Paragraph("Компании: " + formatTop(companies))); + pdf.add(new Paragraph("Персоны: " + formatTop(persons))); + + pdf.add(new Paragraph("Рекомендации")); + pdf.add(new Paragraph("Рассмотрите возможность усиления присутствия в медиа по ключевым темам отчёта.")); + + pdf.add(new Paragraph("Список литературы")); + for (MarketItem mi : items) { + pdf.add(new Paragraph(String.format("%s. Источник: %s. URL: %s", + safe(mi.getTitle()), safe(mi.getSourceName()), safe(mi.getUrl())))); + } + + pdf.add(new Paragraph("Приложения")); + pdf.add(new Paragraph("Полная таблица статей доступна в базе данных за период отчёта.")); + + pdf.close(); + return out.toByteArray(); + } catch (Exception e) { + return new byte[0]; + } + } + + private void addHeading(XWPFDocument doc, String text) { + XWPFParagraph p = doc.createParagraph(); + XWPFRun r = p.createRun(); + r.setBold(true); + r.setFontSize(16); + r.setText(text); + } + + private void addSubheading(XWPFDocument doc, String text) { + XWPFParagraph p = doc.createParagraph(); + XWPFRun r = p.createRun(); + r.setBold(true); + r.setFontSize(13); + r.setText(text); + } + + private void addParagraph(XWPFDocument doc, String text) { + XWPFParagraph p = doc.createParagraph(); + XWPFRun r = p.createRun(); + r.setText(Optional.ofNullable(text).orElse("")); + } + + private Map countSentiment(List items) { + Map map = new LinkedHashMap<>(); + map.put("positive", 0L); + map.put("neutral", 0L); + map.put("negative", 0L); + for (MarketItem mi : items) { + String s = mi.getAnalytics() == null ? null : mi.getAnalytics().getSentiment(); + if (s == null) + continue; + s = s.trim().toLowerCase(); + if (!map.containsKey(s)) + continue; + map.put(s, map.get(s) + 1); + } + return map; + } + + private byte[] buildSentimentChart(Map sentiment) { + DefaultPieDataset dataset = new DefaultPieDataset<>(); + for (Map.Entry e : sentiment.entrySet()) { + dataset.setValue(e.getKey(), e.getValue()); + } + JFreeChart chart = ChartFactory.createPieChart("Sentiment", dataset, true, false, false); + try { + return EncoderUtil.encode(chart.createBufferedImage(800, 500), ImageFormat.PNG); + } catch (Exception ex) { + return null; + } + } + + private Map collectEntityFreq(List items, String key) { + Map freq = new HashMap<>(); + for (MarketItem mi : items) { + Map entities = mi.getAnalytics() == null ? null : mi.getAnalytics().getEntities(); + if (entities == null) + continue; + Object v = entities.get(key); + if (v instanceof Collection col) { + for (Object o : col) { + String name = String.valueOf(o); + if (name == null || name.isBlank()) + continue; + freq.put(name, freq.getOrDefault(name, 0L) + 1); + } + } + } + return freq.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .limit(10) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (a, b) -> a, LinkedHashMap::new)); + } + + private String formatTop(Map m) { + return m.entrySet().stream() + .map(e -> e.getKey() + " (" + e.getValue() + ")") + .collect(Collectors.joining(", ")); + } + + private String safe(String s) { + return s == null ? "" : s; + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 0310489..469b65a 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,3 +1,5 @@ +# Directory to store generated reports +reports.storage.path=/data/reports spring.application.name=parser # MongoDB Configuration diff --git a/Техническое задание (Версия 4.0): Интеграция генерации отчётов в parser-service.md b/Техническое задание (Версия 4.0): Интеграция генерации отчётов в parser-service.md new file mode 100644 index 0000000..175eb9d --- /dev/null +++ b/Техническое задание (Версия 4.0): Интеграция генерации отчётов в parser-service.md @@ -0,0 +1,116 @@ +### Техническое задание (Версия 4.0): Интеграция генерации отчётов в `parser-service` + +**Задача:** Модифицировать существующий `parser-service`, добавив в него функционал для автоматической генерации маркетинговых отчётов. + +**Контекст:** Мы отказываемся от создания отдельного сервиса для отчётов. Вся логика по их созданию должна быть реализована внутри `parser-service`, который уже имеет доступ к данным в MongoDB и к AI-аналитике. + +--- + +## 1\. Архитектура и воркфлоу + +1. **Никаких новых сервисов.** Вся логика реализуется в `parser-service`. +2. **Новый API эндпоинт:** В `parser-service` необходимо добавить новый REST API эндпоинт для запуска процесса генерации отчёта. +3. **Внутренний воркфлоу:** + - Новый эндпоинт (например, в `MarketItemController` или новом `ReportController`) получает запрос с параметрами отчёта. + - Контроллер вызывает новый `ReportGenerationService` (созданный внутри `parser-service`). + - `ReportGenerationService` использует уже существующий `MarketItemRepository` для получения данных за указанный период. + - Для генерации аннотации отчёта он обращается к существующему `OllamaAnalyticsService`. + - Сервис формирует документ в заданном формате (PDF или DOCX). + - Готовый файл возвращается пользователю в теле HTTP-ответа. + +--- + +## 2\. API эндпоинт + +### `POST /api/report/generate` + +Этот эндпоинт будет находиться в `parser-service` и инициировать создание отчёта. + +**Тело запроса (Request Body):** + +```json +{ + "reportTitle": "Еженедельный анализ новостного фона", + "authorName": "Имя Аналитика", + "companyName": "Название Компании Клиента", + "startDate": "2025-09-10T00:00:00", + "endDate": "2025-09-17T23:59:59", + "format": "PDF" // или "DOCX" +} +``` + +**Успешный ответ:** + +- **HTTP Статус:** `200 OK` +- **Headers:** `Content-Disposition: attachment; filename="report_2025-09-17.pdf"` +- **Body:** Бинарные данные сгенерированного файла. + +--- + +## 3\. Структура и содержание отчёта + +Сервис должен динамически генерировать документ, следуя этой структуре: + +### 1\. Титульный лист + +- **Заголовок:** Из поля `reportTitle` запроса. +- **Автор:** Из поля `authorName` запроса. +- **Название компании:** Из поля `companyName` запроса. +- **Дата:** Текущая дата генерации отчёта. + +### 2\. Содержание (Table of Contents) + +- Автоматически генерируемое оглавление со ссылками на основные разделы. + +### 3\. Краткое содержание (Аннотация) + +- **Реализация:** + 1. Получить все **саммари** (`analytics.summary`) статей за выбранный период из MongoDB. + 2. Объединить их в один большой текст. + 3. Отправить этот текст в **Ollama** с промптом: `"На основе этих кратких сводок новостей напиши общую аннотацию на 2-3 абзаца, выделяя ключевые тренды и события."` + +### 4\. Введение + +- Использовать шаблонный текст с динамическими датами. +- **Пример:** `"Целью данного отчёта является анализ новостного фона в сфере маркетинга и бизнеса за период с [startDate] по [endDate]. Были проанализированы публикации из ключевых источников для выявления основных трендов."` + +### 5\. Основная часть + +- **Подраздел: Ключевые публикации** + - Вывести 5-10 самых важных новостей, для каждой указав: **Заголовок, Источник, Дата публикации, Сгенерированное саммари (`analytics.summary`)**. +- **Подраздел: Облако тегов** + - Собрать все теги (`analytics.tags`) и сформировать изображение "облака тегов". +- **Подраздел: Анализ тональности** + - Подсчитать количество статей с тональностью `positive`, `negative`, `neutral` и представить в виде круговой диаграммы. +- **Подраздел: Упоминаемые компании и персоны** + - Собрать и вывести списки наиболее часто упоминаемых компаний и персон из `analytics.entities`. + +### 6\. Рекомендации + +- Использовать статический текст-заполнитель. + +### 7\. Список литературы + +- Автоматически сгенерированный список всех проанализированных статей. +- Формат: `[Заголовок статьи]. Источник: [Название источника]. URL: [Ссылка на статью]` + +### 8\. Приложения + +- Добавить полную таблицу со всеми статьями за период. + +--- + +## 4\. Технологический стек + +- **DOCX:** **Apache POI** +- **PDF:** **OpenPDF** или **iText** +- **Графики и диаграммы:** **JFreeChart** + +--- + +## 5\. Критерии выполнения + +- `parser-service` расширен новым функционалом без создания отдельного сервиса. +- Реализован эндпоинт `POST /api/report/generate`, который принимает параметры отчёта. +- Сервис успешно генерирует и возвращает файлы в форматах PDF и DOCX. +- Структура и содержание сгенерированных документов полностью соответствуют описанию в ТЗ. diff --git a/Техническое задание: Реализация истории и загрузки отчётов.md b/Техническое задание: Реализация истории и загрузки отчётов.md new file mode 100644 index 0000000..4c14a3e --- /dev/null +++ b/Техническое задание: Реализация истории и загрузки отчётов.md @@ -0,0 +1,114 @@ +### Техническое задание: Реализация истории и загрузки отчётов + +**Задача:** Модифицировать `parser-service`, добавив функционал для сохранения, просмотра и скачивания истории сгенерированных отчётов. + +**Контекст:** Текущая реализация позволяет только генерировать отчёты. Необходимо создать механизм для их сохранения и последующего доступа к ним, чтобы пользователи могли скачивать ранее созданные документы. + +--- + +### Шаг 1: Реализация сохранения отчётов + +#### 1.1. Модель данных в MongoDB + +Создайте новую коллекцию в MongoDB для хранения метаданных об отчётах, например, `report_history`. + +**Структура документа `ReportHistory`:** + +```json +{ + "_id": "...", // ObjectId + "reportTitle": "Еженедельный анализ новостного фона", + "authorName": "Имя Аналитика", + "companyName": "Название Компании Клиента", + "startDate": "2025-09-10T00:00:00", + "endDate": "2025-09-17T23:59:59", + "format": "PDF", + "filename": "report_2025-09-17-12345.pdf", // Уникальное имя файла + "filePath": "/data/reports/report_2025-09-17-12345.pdf", // Путь на диске сервера + "fileSize": 1234567, // Размер в байтах + "createdAt": "2025-09-17T21:11:58" // Дата и время генерации +} +``` + +#### 1.2. Хранение файлов + +Сгенерированные отчёты (PDF/DOCX) должны сохраняться на файловой системе сервера в специальной директории. Путь к этой директории должен быть настраиваемым через `application.properties` (например, `reports.storage.path=/data/reports`). + +#### 1.3. Модификация `ReportGenerationService` + +Необходимо изменить существующий сервис `reportGenerationService`. После успешной генерации файла (`ReportBinary`) он должен: + +1. Сохранить бинарные данные файла на диск по указанному пути. +2. Создать запись с метаданными (включая путь к файлу) в новой коллекции `report_history` в MongoDB. + +--- + +### Шаг 2: Создание новых эндпоинтов в `ReportController` + +#### 2.1. Получение списка отчётов из истории + +**Эндпоинт:** `GET /api/parser/report/history` + +**Описание:** Возвращает пагинированный список метаданных всех ранее сгенерированных отчётов, отсортированных по дате создания (сначала новые). + +**Параметры:** + +- `page` (optional, default: 0) - номер страницы. +- `size` (optional, default: 20) - количество элементов на странице. +- `sort` (optional, default: "createdAt,desc") - поле и направление сортировки. + +**Успешный ответ (200 OK):** + +```json +{ + "content": [ + { + "id": "...", + "reportTitle": "Еженедельный анализ", + "authorName": "Имя Аналитика", + "companyName": "Компания", + "format": "PDF", + "filename": "report_2025-09-17.pdf", + "fileSize": 1234567, + "createdAt": "2025-09-17T21:11:58" + } + // ... другие записи + ], + "pageable": { ... }, + "totalElements": 5, + "totalPages": 1 + // ... стандартная структура Page из Spring Data +} +``` + +#### 2.2. Скачивание конкретного отчёта из истории + +**Эндпоинт:** `GET /api/parser/report/history/{id}` + +**Описание:** Позволяет скачать конкретный файл отчёта по его уникальному ID из коллекции `report_history`. + +**Параметры:** + +- `id` (required, path variable) - ID записи об отчёте в MongoDB. + +**Логика работы:** + +1. Найти запись в `report_history` по `id`. +2. Если запись не найдена, вернуть `404 Not Found`. +3. Из записи получить путь к файлу (`filePath`). +4. Прочитать файл с диска по этому пути. +5. Вернуть бинарные данные файла с соответствующими заголовками (`Content-Disposition`, `Content-Type`). + +**Успешный ответ:** + +- **HTTP Статус:** `200 OK` +- **Headers:** `Content-Disposition: attachment; filename="report_2025-09-17.pdf"` +- **Body:** Бинарные данные файла. + +--- + +### Критерии выполнения + +- Существующий эндпоинт `POST /generate` теперь сохраняет сгенерированный отчёт на диск и его метаданные в MongoDB. +- Новый эндпоинт `GET /history` успешно возвращает пагинированный список сохранённых отчётов. +- Новый эндпоинт `GET /history/{id}` успешно находит и отдаёт на скачивание ранее сгенерированный файл.