feat(sentiment): dynamic settings, IFinlyticLogger, live log streaming, and EF migration

This commit is contained in:
2026-08-15 21:30:12 +02:00
parent 1522c3480f
commit 62e030e2cf
10 changed files with 519 additions and 397 deletions
@@ -1,9 +1,13 @@
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;
using Microsoft.Extensions.Logging;
namespace FinlyticSentiment.Services;
@@ -17,53 +21,45 @@ public class SentimentBackgroundService : BackgroundService
private readonly IFinBertAnalyzerService _analyzer;
private readonly ISentimentStorageService _storage;
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<SentimentBackgroundService> _logger;
private readonly IFinlyticLogger<SentimentBackgroundService> _finlyticLogger;
// In-Memory Mutex / Cache zur Vermeidung doppelter Verarbeitung (Race Conditions zwischen Broadcast & Sweep)
private static readonly ConcurrentDictionary<Guid, byte> ProcessingArticles = new();
/// <summary>
/// Initializes a new instance of the <see cref="SentimentBackgroundService"/> class.
/// </summary>
public SentimentBackgroundService(
SentimentMqttClient mqttClient,
IFinBertAnalyzerService analyzer,
ISentimentStorageService storage,
IServiceScopeFactory scopeFactory,
ILogger<SentimentBackgroundService> logger)
IFinlyticLogger<SentimentBackgroundService> finlyticLogger)
{
_mqttClient = mqttClient;
_analyzer = analyzer;
_storage = storage;
_scopeFactory = scopeFactory;
_logger = logger;
_finlyticLogger = finlyticLogger;
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("[{Channel}] FinlyticSentiment Background Service started.", "SentimentChannel");
await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] FinlyticSentiment Background Service started.");
// Realtime Broadcast Event registrieren (Echtzeit-Artikel)
_mqttClient.OnArticleReceived += async (article) =>
{
await ProcessSingleArticleAsync(article, stoppingToken);
};
// Kurze Initialisierungs-Verzögerung für die MQTT-Verbindung
await Task.Delay(3000, stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
int maxBatchSize;
int sweepIntervalMinutes;
int maxBatchSize = 10;
int sweepIntervalMinutes = 5;
using (var scope = _scopeFactory.CreateScope())
{
var settingsDb = scope.ServiceProvider.GetRequiredService<ISettingsDbService>();
var settings = await settingsDb.GetSettingsAsync();
maxBatchSize = settings.MaxBatchSize;
sweepIntervalMinutes = settings.SweepIntervalMinutes;
var settings = scope.ServiceProvider.GetRequiredService<ISettingsService>();
maxBatchSize = await settings.GetSettingAsync(SettingKeys.MaxBatchSize, stoppingToken);
}
var interval = TimeSpan.FromMinutes(Math.Max(1, sweepIntervalMinutes));
@@ -74,19 +70,15 @@ public class SentimentBackgroundService : BackgroundService
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Normales Beenden beim Stoppen des Hosts
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "[{Channel}] Unhandled exception encountered during sentiment sweep cycle.",
"SentimentChannel");
await _finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[SentimentBackgroundService] Unhandled exception encountered during sentiment sweep cycle.");
}
_logger.LogInformation("[{Channel}] Waiting {Minutes} minute(s) until next sentiment sweep...",
"SentimentChannel", interval.TotalMinutes);
await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] Waiting {Minutes} minute(s) until next sentiment sweep...", interval.TotalMinutes);
// Verwendet PeriodicTimer oder CancellationToken-resistenten Delay
using var timer = new PeriodicTimer(interval);
try
{
@@ -98,27 +90,21 @@ public class SentimentBackgroundService : BackgroundService
}
}
_logger.LogInformation("[{Channel}] FinlyticSentiment Background Service is shutting down gracefully.",
"SentimentChannel");
await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] FinlyticSentiment Background Service is shutting down gracefully.");
}
/// <summary>
/// Performs a single batch sweep of pending news articles.
/// </summary>
private async Task PerformSentimentSweepAsync(int maxBatchSize, CancellationToken cancellationToken)
{
_logger.LogInformation("[{Channel}] Starting sentiment sweep for pending news articles (Limit: {Limit})...",
"SentimentChannel", maxBatchSize);
await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] Starting sentiment sweep for pending news articles (Limit: {Limit})...", maxBatchSize);
List<NewsArticleDto> pendingArticles = await _mqttClient.GetPendingArticlesAsync(limit: maxBatchSize);
if (pendingArticles.Count == 0)
{
_logger.LogInformation("[{Channel}] No pending news articles found in FinlyticNews.", "SentimentChannel");
await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] No pending news articles found in FinlyticNews.");
return;
}
_logger.LogInformation("[{Channel}] Retrieved {Count} pending article(s) for FinBERT sentiment evaluation.",
"SentimentChannel", pendingArticles.Count);
await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] Retrieved {Count} pending article(s) for FinBERT sentiment evaluation.", pendingArticles.Count);
foreach (var article in pendingArticles)
{
@@ -127,42 +113,32 @@ public class SentimentBackgroundService : BackgroundService
}
}
/// <summary>
/// Processes FinBERT sentiment analysis for a single article, updates two-stage JSON summaries, and notifies FinlyticNews of status update.
/// </summary>
private async Task ProcessSingleArticleAsync(NewsArticleDto article, CancellationToken cancellationToken = default)
{
if (article == null || article.Id == Guid.Empty) return;
// Deduplizierung: Verhindert, dass derselbe Artikel zeitgleich im Sweep & im Broadcast verarbeitet wird
if (!ProcessingArticles.TryAdd(article.Id, 0))
{
_logger.LogDebug("[{Channel}] Article {Id} is already being processed. Skipping duplicate run.",
"SentimentChannel", article.Id);
await _finlyticLogger.LogDebugAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] Article {Id} is already being processed. Skipping duplicate run.", article.Id);
return;
}
try
{
_logger.LogInformation("[{Channel}] Evaluating FinBERT sentiment for article: '{Title}' (ID: {Id})",
"SentimentChannel", article.Title, article.Id);
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)
{
_logger.LogWarning(
"[{Channel}] FinBERT analysis returned NULL for article {Id} ('{Title}'). Aborting processing for this run.",
"SentimentChannel", article.Id, article.Title);
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;
// 1. Artikel-Level Sentiment speichern
await _storage.SaveArticleSentimentAsync(article, finbert);
// 2. ISIN- & Sektor-Summaries aktualisieren
if (article.MatchedAssets != null && article.MatchedAssets.Count > 0)
{
foreach (var asset in article.MatchedAssets)
@@ -187,29 +163,22 @@ public class SentimentBackgroundService : BackgroundService
if (cancellationToken.IsCancellationRequested) return;
// 3. FinlyticNews über Erfolg informieren
bool updated = await _mqttClient.UpdateArticleStatusAsync(article.Id, "Analyzed");
if (updated)
{
_logger.LogInformation(
"[{Channel}] Article sentiment processed and status set to 'Analyzed' in FinlyticNews: {Title} (ID: {Id}) -> {Label}",
"SentimentChannel", article.Title, article.Id, finbert.Label);
await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] Article sentiment processed and status set to 'Analyzed' in FinlyticNews: {Title} (ID: {Id}) -> {Label}", article.Title, article.Id, finbert.Label);
}
else
{
_logger.LogWarning(
"[{Channel}] Failed to confirm status update to 'Analyzed' in FinlyticNews for article: {Id}",
"SentimentChannel", article.Id);
await _finlyticLogger.LogWarningAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] Failed to confirm status update to 'Analyzed' in FinlyticNews for article: {Id}", article.Id);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[{Channel}] Error processing sentiment for article: {Id} ({Title})",
"SentimentChannel", article.Id, article.Title);
await _finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[SentimentBackgroundService] Error processing sentiment for article: {Id} ({Title})", article.Id, article.Title);
}
finally
{
// Lock nach der Verarbeitung immer freigeben
ProcessingArticles.TryRemove(article.Id, out _);
}
}