analytics added

This commit is contained in:
root
2025-09-17 12:29:11 +05:00
parent 995094fe82
commit 63499c351a
9 changed files with 273 additions and 0 deletions
+6
View File
@@ -62,6 +62,12 @@
<version>1.17.2</version>
</dependency>
<!-- Spring WebFlux for WebClient (for Ollama API calls) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
@@ -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);
@@ -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);
@@ -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);
@@ -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<String, Object> entities = new HashMap<>();
if (entitiesJson != null && entitiesJson.trim().startsWith("{")) {
try {
ObjectMapper mapper = new ObjectMapper();
entities = mapper.readValue(entitiesJson, new TypeReference<Map<String, Object>>() {
});
} 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<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: {}", e.getMessage());
return null;
}
}
}
@@ -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);
@@ -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);
@@ -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
@@ -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<MarketItem> 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` остается пустым, но парсинг не прерывается).