using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using FinlyticCore.Dtos.News; using FinlyticCore.Services; using FinlyticSentiment.Util; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; namespace FinlyticSentiment.Services; /// /// Background hosted worker executing periodic sentiment analysis sweeps on pending news articles /// and processing real-time article broadcasts from FinlyticNews. /// public class SentimentBackgroundService : BackgroundService { private readonly SentimentMqttClient _mqttClient; private readonly IFinBertAnalyzerService _analyzer; private readonly IServiceScopeFactory _scopeFactory; private readonly IFinlyticLogger _finlyticLogger; private static readonly ConcurrentDictionary ProcessingArticles = new(); public SentimentBackgroundService( SentimentMqttClient mqttClient, IFinBertAnalyzerService analyzer, IServiceScopeFactory scopeFactory, IFinlyticLogger finlyticLogger) { _mqttClient = mqttClient; _analyzer = analyzer; _scopeFactory = scopeFactory; _finlyticLogger = finlyticLogger; } /// protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] FinlyticSentiment Background Service started."); _mqttClient.OnArticleReceived += async (article) => { await ProcessSingleArticleAsync(article, stoppingToken); }; await Task.Delay(3000, stoppingToken); while (!stoppingToken.IsCancellationRequested) { int maxBatchSize = 10; int sweepIntervalMinutes = 5; try { using var scope = _scopeFactory.CreateScope(); var settings = scope.ServiceProvider.GetRequiredService(); maxBatchSize = await settings.GetSettingAsync(SettingKeys.MaxBatchSize, stoppingToken); } catch { } var interval = TimeSpan.FromMinutes(Math.Max(1, sweepIntervalMinutes)); try { await PerformSentimentSweepAsync(maxBatchSize, stoppingToken); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } catch (Exception ex) { await _finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[SentimentBackgroundService] Unhandled exception encountered during sentiment sweep cycle."); } await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] Waiting {Minutes} minute(s) until next sentiment sweep...", interval.TotalMinutes); try { await Task.Delay(interval, stoppingToken); } catch (OperationCanceledException) { break; } } await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] FinlyticSentiment Background Service shutting down."); } private async Task PerformSentimentSweepAsync(int maxBatchSize, CancellationToken cancellationToken) { await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] Starting sentiment sweep for pending news articles (Limit: {Limit})...", maxBatchSize); List pendingArticles = await _mqttClient.GetPendingArticlesAsync(limit: maxBatchSize); if (pendingArticles.Count == 0) { await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] No pending news articles found in FinlyticNews."); return; } await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] Retrieved {Count} pending article(s) for FinBERT sentiment evaluation.", pendingArticles.Count); foreach (var article in pendingArticles) { if (cancellationToken.IsCancellationRequested) break; await ProcessSingleArticleAsync(article, cancellationToken); } } private async Task ProcessSingleArticleAsync(NewsArticleDto article, CancellationToken cancellationToken = default) { if (article == null || article.Id == Guid.Empty) return; if (!ProcessingArticles.TryAdd(article.Id, 0)) { await _finlyticLogger.LogDebugAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] Article {Id} is already being processed. Skipping duplicate run.", article.Id); return; } try { await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] Evaluating FinBERT sentiment for article: '{Title}' (ID: {Id})", article.Title, article.Id); var finbert = await _analyzer.AnalyzeArticleAsync(article); if (finbert == null) { await _finlyticLogger.LogWarningAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] FinBERT analysis returned NULL for article {Id} ('{Title}'). Aborting processing for this run.", article.Id, article.Title); return; } if (cancellationToken.IsCancellationRequested) return; using var scope = _scopeFactory.CreateScope(); var dbService = scope.ServiceProvider.GetRequiredService(); if (article.MatchedAssets != null && article.MatchedAssets.Count > 0) { foreach (var asset in article.MatchedAssets) { if (string.IsNullOrWhiteSpace(asset.Isin)) continue; await dbService.SaveArticleSentimentAsync( articleId: article.Id, isin: asset.Isin, companyName: asset.Name, sector: null, publishedAt: article.PublishedAt, finbert: finbert, ct: cancellationToken ); // Broadcast real-time updated summary for this asset var summaryDto = await dbService.GetIsinSummaryDtoAsync(asset.Isin, cancellationToken); if (summaryDto != null) { await _mqttClient.BroadcastSentimentResultAsync(asset.Isin, summaryDto); } } } await _mqttClient.UpdateArticleStatusAsync(article.Id, "Analyzed"); await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] Completed sentiment persistence and status transition for article {Id}.", article.Id); } catch (Exception ex) { await _finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[SentimentBackgroundService] Failed to process sentiment for article: {Id}", article.Id); } finally { ProcessingArticles.TryRemove(article.Id, out _); } } }