parser
This commit is contained in:
@@ -73,6 +73,33 @@
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Validation -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Apache POI for DOCX generation -->
|
||||
<dependency>
|
||||
<groupId>org.apache.poi</groupId>
|
||||
<artifactId>poi-ooxml</artifactId>
|
||||
<version>5.2.5</version>
|
||||
</dependency>
|
||||
|
||||
<!-- OpenPDF for PDF generation -->
|
||||
<dependency>
|
||||
<groupId>com.github.librepdf</groupId>
|
||||
<artifactId>openpdf</artifactId>
|
||||
<version>1.3.40</version>
|
||||
</dependency>
|
||||
|
||||
<!-- JFreeChart for charts -->
|
||||
<dependency>
|
||||
<groupId>org.jfree</groupId>
|
||||
<artifactId>jfreechart</artifactId>
|
||||
<version>1.5.4</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -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<byte[]> 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<Page<ReportHistory>> 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<ReportHistory> res = reportHistoryRepository.findAll(pageable);
|
||||
return ResponseEntity.ok(res);
|
||||
}
|
||||
|
||||
@GetMapping("/history/{id}")
|
||||
public ResponseEntity<byte[]> download(@PathVariable("id") String id) {
|
||||
Optional<ReportHistory> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<ReportHistory, String> {
|
||||
Page<ReportHistory> findAllByOrderByCreatedAtDesc(Pageable pageable);
|
||||
}
|
||||
@@ -116,4 +116,38 @@ public class OllamaAnalyticsService {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public String generateWithInstruction(String text, String instruction) {
|
||||
String prompt = instruction + "\n\n" + text;
|
||||
Map<String, Object> requestBody = new HashMap<>();
|
||||
requestBody.put("model", modelName);
|
||||
requestBody.put("prompt", prompt);
|
||||
requestBody.put("stream", false);
|
||||
|
||||
try {
|
||||
Map<String, Object> response = this.webClient.post()
|
||||
.uri("/api/generate")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.bodyValue(requestBody)
|
||||
.retrieve()
|
||||
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {
|
||||
})
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<MarketItem> 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<MarketItem> 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<MarketItem> 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<MarketItem> 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<String, Long> 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<String, Long> companies = collectEntityFreq(items, "companies");
|
||||
Map<String, Long> 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<MarketItem> 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<MarketItem> 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<String, Long> sentiment = countSentiment(items);
|
||||
byte[] piePng = buildSentimentChart(sentiment);
|
||||
if (piePng != null) {
|
||||
Image img = Image.getInstance(piePng);
|
||||
img.scaleToFit(480, 320);
|
||||
pdf.add(img);
|
||||
}
|
||||
|
||||
Map<String, Long> companies = collectEntityFreq(items, "companies");
|
||||
Map<String, Long> 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<String, Long> countSentiment(List<MarketItem> items) {
|
||||
Map<String, Long> 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<String, Long> sentiment) {
|
||||
DefaultPieDataset<String> dataset = new DefaultPieDataset<>();
|
||||
for (Map.Entry<String, Long> 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<String, Long> collectEntityFreq(List<MarketItem> items, String key) {
|
||||
Map<String, Long> freq = new HashMap<>();
|
||||
for (MarketItem mi : items) {
|
||||
Map<String, Object> 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.<String, Long>comparingByValue().reversed())
|
||||
.limit(10)
|
||||
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (a, b) -> a, LinkedHashMap::new));
|
||||
}
|
||||
|
||||
private String formatTop(Map<String, Long> m) {
|
||||
return m.entrySet().stream()
|
||||
.map(e -> e.getKey() + " (" + e.getValue() + ")")
|
||||
.collect(Collectors.joining(", "));
|
||||
}
|
||||
|
||||
private String safe(String s) {
|
||||
return s == null ? "" : s;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
# Directory to store generated reports
|
||||
reports.storage.path=/data/reports
|
||||
spring.application.name=parser
|
||||
|
||||
# MongoDB Configuration
|
||||
|
||||
@@ -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.
|
||||
- Структура и содержание сгенерированных документов полностью соответствуют описанию в ТЗ.
|
||||
@@ -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}` успешно находит и отдаёт на скачивание ранее сгенерированный файл.
|
||||
Reference in New Issue
Block a user