From 63499c351a68fc86d3d69c1b215a2a673cd5fbab Mon Sep 17 00:00:00 2001 From: root Date: Wed, 17 Sep 2025 12:29:11 +0500 Subject: [PATCH] analytics added --- pom.xml | 6 + .../parser/service/KapitalParserService.java | 10 ++ .../parser/service/KursivParserService.java | 10 ++ .../parser/service/LsmParserService.java | 10 ++ .../service/OllamaAnalyticsService.java | 119 ++++++++++++++++++ .../parser/service/RbcParserService.java | 10 ++ .../service/VedomostiParserService.java | 10 ++ src/main/resources/application.properties | 5 + ...нтеграция AI-аналитики с помощью Ollama.md | 93 ++++++++++++++ 9 files changed, 273 insertions(+) create mode 100644 src/main/java/kz/konturai/parser/service/OllamaAnalyticsService.java create mode 100644 Техническое задание: Интеграция AI-аналитики с помощью Ollama.md diff --git a/pom.xml b/pom.xml index c4dfe0b..0577d56 100644 --- a/pom.xml +++ b/pom.xml @@ -62,6 +62,12 @@ 1.17.2 + + + org.springframework.boot + spring-boot-starter-webflux + + org.springframework.boot spring-boot-starter-test diff --git a/src/main/java/kz/konturai/parser/service/KapitalParserService.java b/src/main/java/kz/konturai/parser/service/KapitalParserService.java index 3efcc3e..1124396 100644 --- a/src/main/java/kz/konturai/parser/service/KapitalParserService.java +++ b/src/main/java/kz/konturai/parser/service/KapitalParserService.java @@ -32,6 +32,9 @@ public class KapitalParserService implements ParserService { @Autowired private MarketItemRepository marketItemRepository; + @Autowired + private OllamaAnalyticsService analyticsService; + @Value("${rss.kapital.url}") private String rssFeedUrl; @@ -79,6 +82,13 @@ public class KapitalParserService implements ParserService { try { MarketItem marketItem = processRssEntry(entry); if (marketItem != null) { + try { + MarketItem.Analytics analytics = analyticsService.analyzeText(marketItem.getRawText()); + marketItem.setAnalytics(analytics); + } catch (Exception e) { + logger.warn("Analytics step failed for Kapital '{}': {}", marketItem.getTitle(), + e.getMessage()); + } MarketItem savedItem = saveOrUpdateMarketItem(marketItem); if (savedItem != null) { savedItems.add(savedItem); diff --git a/src/main/java/kz/konturai/parser/service/KursivParserService.java b/src/main/java/kz/konturai/parser/service/KursivParserService.java index ec3fbfa..b6812f4 100644 --- a/src/main/java/kz/konturai/parser/service/KursivParserService.java +++ b/src/main/java/kz/konturai/parser/service/KursivParserService.java @@ -32,6 +32,9 @@ public class KursivParserService implements ParserService { @Autowired private MarketItemRepository marketItemRepository; + @Autowired + private OllamaAnalyticsService analyticsService; + @Value("${rss.feed.url}") private String rssFeedUrl; @@ -79,6 +82,13 @@ public class KursivParserService implements ParserService { try { MarketItem marketItem = processRssEntry(entry); if (marketItem != null) { + try { + MarketItem.Analytics analytics = analyticsService.analyzeText(marketItem.getRawText()); + marketItem.setAnalytics(analytics); + } catch (Exception e) { + logger.warn("Analytics step failed for entry '{}': {}", marketItem.getTitle(), + e.getMessage()); + } MarketItem savedItem = saveOrUpdateMarketItem(marketItem); if (savedItem != null) { savedItems.add(savedItem); diff --git a/src/main/java/kz/konturai/parser/service/LsmParserService.java b/src/main/java/kz/konturai/parser/service/LsmParserService.java index 6b73e6f..ecb000a 100644 --- a/src/main/java/kz/konturai/parser/service/LsmParserService.java +++ b/src/main/java/kz/konturai/parser/service/LsmParserService.java @@ -32,6 +32,9 @@ public class LsmParserService implements ParserService { @Autowired private MarketItemRepository marketItemRepository; + @Autowired + private OllamaAnalyticsService analyticsService; + @Value("${rss.lsm.url}") private String rssFeedUrl; @@ -79,6 +82,13 @@ public class LsmParserService implements ParserService { try { MarketItem marketItem = processRssEntry(entry); if (marketItem != null) { + try { + MarketItem.Analytics analytics = analyticsService.analyzeText(marketItem.getRawText()); + marketItem.setAnalytics(analytics); + } catch (Exception e) { + logger.warn("Analytics step failed for LSM '{}': {}", marketItem.getTitle(), + e.getMessage()); + } MarketItem savedItem = saveOrUpdateMarketItem(marketItem); if (savedItem != null) { savedItems.add(savedItem); diff --git a/src/main/java/kz/konturai/parser/service/OllamaAnalyticsService.java b/src/main/java/kz/konturai/parser/service/OllamaAnalyticsService.java new file mode 100644 index 0000000..d4c8c94 --- /dev/null +++ b/src/main/java/kz/konturai/parser/service/OllamaAnalyticsService.java @@ -0,0 +1,119 @@ +package kz.konturai.parser.service; + +import kz.konturai.parser.model.MarketItem; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.core.type.TypeReference; +import org.springframework.core.ParameterizedTypeReference; + +import java.util.HashMap; +import java.util.Map; + +@Service +public class OllamaAnalyticsService { + + private static final Logger logger = LoggerFactory.getLogger(OllamaAnalyticsService.class); + + private final WebClient webClient; + + @Value("${ollama.model:gemma3:1b}") + private String modelName; + + public OllamaAnalyticsService(@Value("${ollama.host:http://185.35.223.45:11434}") String ollamaHost) { + this.webClient = WebClient.builder() + .baseUrl(ollamaHost) + .build(); + } + + public MarketItem.Analytics analyzeText(String text) { + MarketItem.Analytics analytics = new MarketItem.Analytics(); + + try { + String summary = generate(text, + "Напиши краткое саммари следующей новостной статьи на русском языке. Ответ должен содержать только саммари из 3-4 предложений, без лишних вступлений. Статья: "); + analytics.setSummary(summary); + } catch (Exception e) { + logger.warn("Failed to get summary from Ollama: {}", e.getMessage()); + } + + try { + String tagsCsv = generate(text, + "Извлеки 5-7 ключевых слов или тегов из текста новостной статьи. В ответе дай только список тегов через запятую, без нумерации и заголовков. Статья: "); + String[] tags = tagsCsv == null ? new String[] {} : tagsCsv.replace("\n", " ").split("\\s*,\\s*"); + analytics.setTags(tags); + } catch (Exception e) { + logger.warn("Failed to get tags from Ollama: {}", e.getMessage()); + } + + try { + String sentiment = generate(text, + "Определи тональность текста новостной статьи. В ответе дай только одно слово латиницей: positive, negative или neutral. Статья: "); + if (sentiment != null) { + sentiment = sentiment.trim().toLowerCase(); + } + analytics.setSentiment(sentiment); + } catch (Exception e) { + logger.warn("Failed to get sentiment from Ollama: {}", e.getMessage()); + } + + try { + String entitiesJson = generate(text, + "Извлеки из текста имена людей, названия компаний и географические локации. В ответе дай только JSON объект следующей структуры: {\\\"persons\\\": [], \\\"companies\\\": [], \\\"locations\\\": []}. Статья: "); + Map entities = new HashMap<>(); + if (entitiesJson != null && entitiesJson.trim().startsWith("{")) { + try { + ObjectMapper mapper = new ObjectMapper(); + entities = mapper.readValue(entitiesJson, new TypeReference>() { + }); + } catch (Exception parseEx) { + entities.put("raw", entitiesJson); + } + } + analytics.setEntities(entities); + } catch (Exception e) { + logger.warn("Failed to get entities from Ollama: {}", e.getMessage()); + } + + return analytics; + } + + private String generate(String text, String instructionPrefix) { + String prompt = instructionPrefix + 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: {}", e.getMessage()); + return null; + } + } +} diff --git a/src/main/java/kz/konturai/parser/service/RbcParserService.java b/src/main/java/kz/konturai/parser/service/RbcParserService.java index bd5b11e..6d8474b 100644 --- a/src/main/java/kz/konturai/parser/service/RbcParserService.java +++ b/src/main/java/kz/konturai/parser/service/RbcParserService.java @@ -32,6 +32,9 @@ public class RbcParserService implements ParserService { @Autowired private MarketItemRepository marketItemRepository; + @Autowired + private OllamaAnalyticsService analyticsService; + @Value("${rss.rbc.url}") private String rssFeedUrl; @@ -79,6 +82,13 @@ public class RbcParserService implements ParserService { try { MarketItem marketItem = processRssEntry(entry); if (marketItem != null) { + try { + MarketItem.Analytics analytics = analyticsService.analyzeText(marketItem.getRawText()); + marketItem.setAnalytics(analytics); + } catch (Exception e) { + logger.warn("Analytics step failed for RBC '{}': {}", marketItem.getTitle(), + e.getMessage()); + } MarketItem savedItem = saveOrUpdateMarketItem(marketItem); if (savedItem != null) { savedItems.add(savedItem); diff --git a/src/main/java/kz/konturai/parser/service/VedomostiParserService.java b/src/main/java/kz/konturai/parser/service/VedomostiParserService.java index 6db7dba..89dc302 100644 --- a/src/main/java/kz/konturai/parser/service/VedomostiParserService.java +++ b/src/main/java/kz/konturai/parser/service/VedomostiParserService.java @@ -32,6 +32,9 @@ public class VedomostiParserService implements ParserService { @Autowired private MarketItemRepository marketItemRepository; + @Autowired + private OllamaAnalyticsService analyticsService; + @Value("${rss.vedomosti.url}") private String rssFeedUrl; @@ -79,6 +82,13 @@ public class VedomostiParserService implements ParserService { try { MarketItem marketItem = processRssEntry(entry); if (marketItem != null) { + try { + MarketItem.Analytics analytics = analyticsService.analyzeText(marketItem.getRawText()); + marketItem.setAnalytics(analytics); + } catch (Exception e) { + logger.warn("Analytics step failed for Vedomosti '{}': {}", marketItem.getTitle(), + e.getMessage()); + } MarketItem savedItem = saveOrUpdateMarketItem(marketItem); if (savedItem != null) { savedItems.add(savedItem); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index d530e65..0310489 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -37,3 +37,8 @@ logging.level.kz.konturai.parser.service.VedomostiParserService=INFO 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 diff --git a/Техническое задание: Интеграция AI-аналитики с помощью Ollama.md b/Техническое задание: Интеграция AI-аналитики с помощью Ollama.md new file mode 100644 index 0000000..cdc34e1 --- /dev/null +++ b/Техническое задание: Интеграция AI-аналитики с помощью Ollama.md @@ -0,0 +1,93 @@ +### Техническое задание (Версия 2.0): Интеграция AI-аналитики в `parser-service` + +**Задача:** Модифицировать существующий `parser-service` для обогащения новостей аналитическими данными (саммари, теги, тональность) **в момент парсинга**, перед сохранением в базу данных. + +**Контекст:** Мы отказываемся от создания отдельного `analytics-service` в пользу более простой, монолитной архитектуры. Аналитика должна стать неотъемлемой частью процесса парсинга. Каждая новость, попадающая в базу данных, должна уже содержать сгенерированные AI-данные. + +--- + +## 1\. Архитектурные изменения + +- **Никаких новых сервисов.** Вся логика реализуется внутри существующего `parser-service`. +- **Синхронный процесс:** Новый воркфлоу для каждой новости: **Парсинг -\> Аналитика -\> Сохранение в MongoDB**. +- **Новый компонент:** Внутри `parser-service` необходимо создать новый сервис/компонент (например, `OllamaAnalyticsService`), который будет отвечать за все взаимодействия с API Ollama. + +--- + +## 2\. Основные требования + +### 2.1. Создание `OllamaAnalyticsService` + +В проекте `parser-service` создайте новый сервис, который будет инкапсулировать логику общения с Ollama. + +- **Взаимодействие с API Ollama:** + - **Хост:** `http://185.35.223.45:11434` + - **Модель:** `gemma3:1b` + - **Клиент:** Использовать `WebClient` для HTTP-запросов к эндпоинту `/api/generate`. +- **Основной метод:** У сервиса должен быть публичный метод, например `Analytics analyzeText(String text)`, который принимает сырой текст статьи и возвращает готовый объект `Analytics` со всеми заполненными полями. + +### 2.2. Модификация существующих парсеров + +Необходимо изменить логику **каждого** существующего парсера (`KursivParserService`, `KapitalParserService` и т.д.). + +**Новый алгоритм работы для метода `parseAndSaveRssFeed()`:** + +1. Получить и разобрать данные из RSS-ленты. +2. Для каждой новости, после извлечения `raw_text`, **вызвать** метод `analyzeText` из нового `OllamaAnalyticsService`. +3. Получить в ответ заполненный объект `Analytics`. +4. Установить этот объект в поле `analytics` у сущности `MarketItem`. +5. **Только после этого** сохранить полностью обогащенный `MarketItem` в MongoDB. + +**Примерный псевдокод для `KursivParserService`:** + +```java +@Service +public class KursivParserService implements ParserService { + + @Autowired + private OllamaAnalyticsService analyticsService; // Новый сервис + @Autowired + private MarketItemRepository repository; + + @Override + public List parseAndSaveRssFeed() { + // ... логика получения данных из RSS ... + + for (RssItem rssItem : feedItems) { + MarketItem marketItem = new MarketItem(); + marketItem.setTitle(rssItem.getTitle()); + marketItem.setRawText(rssItem.getText()); + // ... установить остальные поля ... + + // === НОВЫЙ ШАГ === + // Вызываем аналитику ПЕРЕД сохранением + Analytics analyticsData = analyticsService.analyzeText(rssItem.getText()); + marketItem.setAnalytics(analyticsData); + // ================== + + // Сохраняем уже обогащенный объект + repository.save(marketItem); + } + // ... вернуть результат ... + } +} +``` + +### 2.3. Промпты для Ollama + +Используйте следующие промпты для каждой аналитической задачи внутри `OllamaAnalyticsService`: + +- **Саммари:** `Напиши краткое саммари следующей новостной статьи на русском языке. Ответ должен содержать только саммари из 3-4 предложений, без лишних вступлений. Статья: [Текст статьи]` +- **Теги:** `Извлеки 5-7 ключевых слов или тегов из текста новостной статьи. В ответе дай только список тегов через запятую, без нумерации и заголовков. Статья: [Текст статьи]` +- **Тональность:** `Определи тональность текста новостной статьи. В ответе дай только одно слово латиницей: positive, negative или neutral. Статья: [Текст статьи]` +- **Сущности (JSON):** `Извлеки из текста имена людей, названия компаний и географические локации. В ответе дай только JSON объект следующей структуры: {"persons": [], "companies": [], "locations": []}. Статья: [Текст статьи]` + +--- + +## Критерии выполнения + +- Новый сервис `OllamaAnalyticsService` создан внутри проекта `parser-service`. +- Существующие парсеры (`KursivParserService` и др.) модифицированы для вызова `OllamaAnalyticsService` перед сохранением данных. +- Все новые записи, сохраняемые в MongoDB, **сразу содержат** заполненное поле `analytics`. +- Процесс парсинга теперь может занимать больше времени, это ожидаемое поведение. +- Реализована базовая обработка ошибок (например, если Ollama недоступен, поле `analytics` остается пустым, но парсинг не прерывается).