This commit is contained in:
root
2025-09-21 16:05:22 +05:00
parent 63499c351a
commit 26fa02392d
10 changed files with 951 additions and 0 deletions
@@ -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