analytics added
This commit is contained in:
@@ -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` остается пустым, но парсинг не прерывается).
|
||||
Reference in New Issue
Block a user