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,23 +1,33 @@
using FinlyticCore.Database;
using FinlyticCore.Entities.Settings;
using FinlyticSentiment.Entities; using FinlyticSentiment.Entities;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace FinlyticSentiment.Database; namespace FinlyticSentiment.Database;
/// <summary> /// <summary>
/// EF Core DbContext for managing FinlyticSentiment settings in PostgreSQL. /// EF Core DbContext for managing FinlyticSentiment settings in PostgreSQL.
/// </summary> /// </summary>
public class SentimentDbContext : DbContext public class SentimentDbContext : DbContext, ISettingsDbContext
{ {
public SentimentDbContext(DbContextOptions<SentimentDbContext> options) : base(options) public SentimentDbContext(DbContextOptions<SentimentDbContext> options) : base(options)
{ {
} }
public DbSet<SettingEntity> DynamicSettings => Set<SettingEntity>();
public DbSet<SentimentSettingsEntity> Settings => Set<SentimentSettingsEntity>(); public DbSet<SentimentSettingsEntity> Settings => Set<SentimentSettingsEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
modelBuilder.Entity<SettingEntity>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.Key).IsUnique();
});
modelBuilder.Entity<SentimentSettingsEntity>(entity => modelBuilder.Entity<SentimentSettingsEntity>(entity =>
{ {
entity.ToTable("sentiment_settings"); entity.ToTable("sentiment_settings");
@@ -27,3 +37,13 @@ public class SentimentDbContext : DbContext
}); });
} }
} }
public class SentimentDbContextFactory : IDesignTimeDbContextFactory<SentimentDbContext>
{
public SentimentDbContext CreateDbContext(string[] args)
{
var optionsBuilder = new DbContextOptionsBuilder<SentimentDbContext>();
optionsBuilder.UseNpgsql("Host=localhost;Database=sentiment;Username=postgres;Password=postgres");
return new SentimentDbContext(optionsBuilder.Options);
}
}
@@ -0,0 +1,94 @@
// <auto-generated />
using System;
using FinlyticSentiment.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FinlyticSentiment.Migrations
{
[DbContext(typeof(SentimentDbContext))]
[Migration("20260815184006_AddDynamicSettings")]
partial class AddDynamicSettings
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<DateTime>("LastUpdatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("ServiceIdentifier")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ValueJson")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.ToTable("DynamicSettings");
});
modelBuilder.Entity("FinlyticSentiment.Entities.SentimentSettingsEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("EnglishWebhookUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("GermanWebhookUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<int>("MaxBatchSize")
.HasColumnType("integer");
b.Property<double>("MinConfidenceScore")
.HasColumnType("double precision");
b.Property<int>("SweepIntervalMinutes")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("sentiment_settings", (string)null);
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,43 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FinlyticSentiment.Migrations
{
/// <inheritdoc />
public partial class AddDynamicSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DynamicSettings",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Key = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: false),
ValueJson = table.Column<string>(type: "text", nullable: false),
ServiceIdentifier = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
LastUpdatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DynamicSettings", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_DynamicSettings_Key",
table: "DynamicSettings",
column: "Key",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DynamicSettings");
}
}
}
@@ -22,6 +22,37 @@ namespace FinlyticSentiment.Migrations
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<DateTime>("LastUpdatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("ServiceIdentifier")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ValueJson")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.ToTable("DynamicSettings");
});
modelBuilder.Entity("FinlyticSentiment.Entities.SentimentSettingsEntity", b => modelBuilder.Entity("FinlyticSentiment.Entities.SentimentSettingsEntity", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
+12 -2
View File
@@ -1,13 +1,24 @@
using System;
using FinlyticCore.Database;
using FinlyticCore.Services;
using FinlyticSentiment.Database; using FinlyticSentiment.Database;
using FinlyticSentiment.Services; using FinlyticSentiment.Services;
using FinlyticSentiment.Util; using FinlyticSentiment.Util;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
var builder = Host.CreateApplicationBuilder(args); var builder = Host.CreateApplicationBuilder(args);
// Register PostgreSQL DbContext for settings persistence // Register PostgreSQL DbContext for settings persistence
builder.Services.AddDbContext<SentimentDbContext>(options => builder.Services.AddDbContext<SentimentDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddScoped<ISettingsDbContext>(sp => sp.GetRequiredService<SentimentDbContext>());
// Register Core Services
builder.Services.AddSingleton<ISettingsService, SettingsService>();
builder.Services.AddSingleton(typeof(IFinlyticLogger<>), typeof(FinlyticLogger<>));
// Register HttpClient // Register HttpClient
builder.Services.AddHttpClient(); builder.Services.AddHttpClient();
@@ -36,8 +47,7 @@ using (var scope = host.Services.CreateScope())
} }
catch (Exception ex) catch (Exception ex)
{ {
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>(); Console.WriteLine($"Critical error during database migration for FinlyticSentiment: {ex.Message}");
logger.LogError(ex, "An error occurred during database migration for FinlyticSentiment on startup.");
} }
} }
@@ -1,9 +1,13 @@
using System;
using System.Net.Http;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks;
using FinlyticCore.Dtos.News; using FinlyticCore.Dtos.News;
using FinlyticCore.Dtos.Sentiment; using FinlyticCore.Dtos.Sentiment;
using Microsoft.Extensions.Configuration; using FinlyticCore.Services;
using Microsoft.Extensions.Logging; using FinlyticSentiment.Util;
using Microsoft.Extensions.DependencyInjection;
namespace FinlyticSentiment.Services; namespace FinlyticSentiment.Services;
@@ -15,8 +19,6 @@ public interface IFinBertAnalyzerService
/// <summary> /// <summary>
/// Analyzes a news article using language-targeted FinBERT webhooks and returns structured metrics. /// Analyzes a news article using language-targeted FinBERT webhooks and returns structured metrics.
/// </summary> /// </summary>
/// <param name="article">The news article DTO to evaluate.</param>
/// <returns>A task returning the FinBERT sentiment analysis result.</returns>
Task<FinBertResultDto?> AnalyzeArticleAsync(NewsArticleDto article); Task<FinBertResultDto?> AnalyzeArticleAsync(NewsArticleDto article);
} }
@@ -27,22 +29,16 @@ public class FinBertAnalyzerService : IFinBertAnalyzerService
{ {
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly IServiceScopeFactory _scopeFactory; private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<FinBertAnalyzerService> _logger; private readonly IFinlyticLogger<FinBertAnalyzerService> _finlyticLogger;
/// <summary>
/// Initializes a new instance of the <see cref="FinBertAnalyzerService"/> class.
/// </summary>
/// <param name="httpClient">The HTTP client instance.</param>
/// <param name="scopeFactory">The service scope factory for DB access.</param>
/// <param name="logger">The logging channel.</param>
public FinBertAnalyzerService( public FinBertAnalyzerService(
HttpClient httpClient, HttpClient httpClient,
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
ILogger<FinBertAnalyzerService> logger) IFinlyticLogger<FinBertAnalyzerService> finlyticLogger)
{ {
_httpClient = httpClient; _httpClient = httpClient;
_scopeFactory = scopeFactory; _scopeFactory = scopeFactory;
_logger = logger; _finlyticLogger = finlyticLogger;
} }
/// <summary> /// <summary>
@@ -64,7 +60,7 @@ public class FinBertAnalyzerService : IFinBertAnalyzerService
minConfidence = settings.MinConfidenceScore; minConfidence = settings.MinConfidenceScore;
} }
_logger.LogInformation("[{Channel}] Analyzing article (ID: {Id}, Lang: {Lang}) via webhook: {Url} (MinConf: {Conf})", "SentimentChannel", article.Id, article.Language ?? "de", targetUrl, minConfidence); await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[FinBertAnalyzerService] Analyzing article (ID: {Id}, Lang: {Lang}) via webhook: {Url} (MinConf: {Conf})", article.Id, article.Language ?? "de", targetUrl, minConfidence);
var requestBody = new var requestBody = new
{ {
@@ -88,13 +84,11 @@ public class FinBertAnalyzerService : IFinBertAnalyzerService
using var doc = JsonDocument.Parse(content); using var doc = JsonDocument.Parse(content);
var root = doc.RootElement; var root = doc.RootElement;
// Handle array response if n8n returns an array of items (e.g. [{ "json": { ... } }])
if (root.ValueKind == JsonValueKind.Array && root.GetArrayLength() > 0) if (root.ValueKind == JsonValueKind.Array && root.GetArrayLength() > 0)
{ {
root = root[0]; root = root[0];
} }
// Unwrap n8n wrapper objects: "json", "output", "data", "result", "body"
if (root.ValueKind == JsonValueKind.Object) if (root.ValueKind == JsonValueKind.Object)
{ {
if (root.TryGetProperty("json", out var jsonChild) && jsonChild.ValueKind == JsonValueKind.Object) if (root.TryGetProperty("json", out var jsonChild) && jsonChild.ValueKind == JsonValueKind.Object)
@@ -134,7 +128,6 @@ public class FinBertAnalyzerService : IFinBertAnalyzerService
neu = GetDoubleProp(probsElem, "neutral") ?? neu; neu = GetDoubleProp(probsElem, "neutral") ?? neu;
} }
// Normalize German vs English labels
string label = rawLabel.Trim().ToUpperInvariant() switch string label = rawLabel.Trim().ToUpperInvariant() switch
{ {
"POSITIV" or "POSITIVE" => "POSITIVE", "POSITIV" or "POSITIVE" => "POSITIVE",
@@ -142,7 +135,6 @@ public class FinBertAnalyzerService : IFinBertAnalyzerService
_ => "NEUTRAL" _ => "NEUTRAL"
}; };
// If compoundScore is 0 but probabilities or label indicate sentiment, compute compoundScore
if (Math.Abs(compoundScore) < 0.001) if (Math.Abs(compoundScore) < 0.001)
{ {
if (pos > 0 || neg > 0) if (pos > 0 || neg > 0)
@@ -173,15 +165,14 @@ public class FinBertAnalyzerService : IFinBertAnalyzerService
SummarySnippet = snippet SummarySnippet = snippet
}; };
} }
} }
} }
_logger.LogWarning("[{Channel}] n8n Webhook returned non-success status: {StatusCode}. Falling back to rule analyzer.", "SentimentChannel", response.StatusCode); await _finlyticLogger.LogWarningAsync(SettingKeys.SentimentChannel, "[FinBertAnalyzerService] n8n Webhook returned non-success status: {StatusCode}.", response.StatusCode);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "[{Channel}] Failed to call n8n sentiment webhook. Executing fallback sentiment analyzer.", "SentimentChannel"); await _finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[FinBertAnalyzerService] Failed to call n8n sentiment webhook.");
} }
return null; return null;
@@ -1,9 +1,13 @@
using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.News; using FinlyticCore.Dtos.News;
using FinlyticCore.Services;
using FinlyticSentiment.Util; using FinlyticSentiment.Util;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace FinlyticSentiment.Services; namespace FinlyticSentiment.Services;
@@ -17,53 +21,45 @@ public class SentimentBackgroundService : BackgroundService
private readonly IFinBertAnalyzerService _analyzer; private readonly IFinBertAnalyzerService _analyzer;
private readonly ISentimentStorageService _storage; private readonly ISentimentStorageService _storage;
private readonly IServiceScopeFactory _scopeFactory; 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(); private static readonly ConcurrentDictionary<Guid, byte> ProcessingArticles = new();
/// <summary>
/// Initializes a new instance of the <see cref="SentimentBackgroundService"/> class.
/// </summary>
public SentimentBackgroundService( public SentimentBackgroundService(
SentimentMqttClient mqttClient, SentimentMqttClient mqttClient,
IFinBertAnalyzerService analyzer, IFinBertAnalyzerService analyzer,
ISentimentStorageService storage, ISentimentStorageService storage,
IServiceScopeFactory scopeFactory, IServiceScopeFactory scopeFactory,
ILogger<SentimentBackgroundService> logger) IFinlyticLogger<SentimentBackgroundService> finlyticLogger)
{ {
_mqttClient = mqttClient; _mqttClient = mqttClient;
_analyzer = analyzer; _analyzer = analyzer;
_storage = storage; _storage = storage;
_scopeFactory = scopeFactory; _scopeFactory = scopeFactory;
_logger = logger; _finlyticLogger = finlyticLogger;
} }
/// <inheritdoc /> /// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken) 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) => _mqttClient.OnArticleReceived += async (article) =>
{ {
await ProcessSingleArticleAsync(article, stoppingToken); await ProcessSingleArticleAsync(article, stoppingToken);
}; };
// Kurze Initialisierungs-Verzögerung für die MQTT-Verbindung
await Task.Delay(3000, stoppingToken); await Task.Delay(3000, stoppingToken);
while (!stoppingToken.IsCancellationRequested) while (!stoppingToken.IsCancellationRequested)
{ {
int maxBatchSize; int maxBatchSize = 10;
int sweepIntervalMinutes; int sweepIntervalMinutes = 5;
using (var scope = _scopeFactory.CreateScope()) using (var scope = _scopeFactory.CreateScope())
{ {
var settingsDb = scope.ServiceProvider.GetRequiredService<ISettingsDbService>(); var settings = scope.ServiceProvider.GetRequiredService<ISettingsService>();
var settings = await settingsDb.GetSettingsAsync(); maxBatchSize = await settings.GetSettingAsync(SettingKeys.MaxBatchSize, stoppingToken);
maxBatchSize = settings.MaxBatchSize;
sweepIntervalMinutes = settings.SweepIntervalMinutes;
} }
var interval = TimeSpan.FromMinutes(Math.Max(1, sweepIntervalMinutes)); var interval = TimeSpan.FromMinutes(Math.Max(1, sweepIntervalMinutes));
@@ -74,19 +70,15 @@ public class SentimentBackgroundService : BackgroundService
} }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{ {
// Normales Beenden beim Stoppen des Hosts
break; break;
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "[{Channel}] Unhandled exception encountered during sentiment sweep cycle.", await _finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[SentimentBackgroundService] Unhandled exception encountered during sentiment sweep cycle.");
"SentimentChannel");
} }
_logger.LogInformation("[{Channel}] Waiting {Minutes} minute(s) until next sentiment sweep...", await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] Waiting {Minutes} minute(s) until next sentiment sweep...", interval.TotalMinutes);
"SentimentChannel", interval.TotalMinutes);
// Verwendet PeriodicTimer oder CancellationToken-resistenten Delay
using var timer = new PeriodicTimer(interval); using var timer = new PeriodicTimer(interval);
try try
{ {
@@ -98,27 +90,21 @@ public class SentimentBackgroundService : BackgroundService
} }
} }
_logger.LogInformation("[{Channel}] FinlyticSentiment Background Service is shutting down gracefully.", await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] FinlyticSentiment Background Service is shutting down gracefully.");
"SentimentChannel");
} }
/// <summary>
/// Performs a single batch sweep of pending news articles.
/// </summary>
private async Task PerformSentimentSweepAsync(int maxBatchSize, CancellationToken cancellationToken) private async Task PerformSentimentSweepAsync(int maxBatchSize, CancellationToken cancellationToken)
{ {
_logger.LogInformation("[{Channel}] Starting sentiment sweep for pending news articles (Limit: {Limit})...", await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] Starting sentiment sweep for pending news articles (Limit: {Limit})...", maxBatchSize);
"SentimentChannel", maxBatchSize);
List<NewsArticleDto> pendingArticles = await _mqttClient.GetPendingArticlesAsync(limit: maxBatchSize); List<NewsArticleDto> pendingArticles = await _mqttClient.GetPendingArticlesAsync(limit: maxBatchSize);
if (pendingArticles.Count == 0) 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; return;
} }
_logger.LogInformation("[{Channel}] Retrieved {Count} pending article(s) for FinBERT sentiment evaluation.", await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] Retrieved {Count} pending article(s) for FinBERT sentiment evaluation.", pendingArticles.Count);
"SentimentChannel", pendingArticles.Count);
foreach (var article in pendingArticles) 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) private async Task ProcessSingleArticleAsync(NewsArticleDto article, CancellationToken cancellationToken = default)
{ {
if (article == null || article.Id == Guid.Empty) return; 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)) if (!ProcessingArticles.TryAdd(article.Id, 0))
{ {
_logger.LogDebug("[{Channel}] Article {Id} is already being processed. Skipping duplicate run.", await _finlyticLogger.LogDebugAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] Article {Id} is already being processed. Skipping duplicate run.", article.Id);
"SentimentChannel", article.Id);
return; return;
} }
try try
{ {
_logger.LogInformation("[{Channel}] Evaluating FinBERT sentiment for article: '{Title}' (ID: {Id})", await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] Evaluating FinBERT sentiment for article: '{Title}' (ID: {Id})", article.Title, article.Id);
"SentimentChannel", article.Title, article.Id);
var finbert = await _analyzer.AnalyzeArticleAsync(article); var finbert = await _analyzer.AnalyzeArticleAsync(article);
if (finbert == null) if (finbert == null)
{ {
_logger.LogWarning( await _finlyticLogger.LogWarningAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] FinBERT analysis returned NULL for article {Id} ('{Title}'). Aborting processing for this run.", article.Id, article.Title);
"[{Channel}] FinBERT analysis returned NULL for article {Id} ('{Title}'). Aborting processing for this run.",
"SentimentChannel", article.Id, article.Title);
return; return;
} }
if (cancellationToken.IsCancellationRequested) return; if (cancellationToken.IsCancellationRequested) return;
// 1. Artikel-Level Sentiment speichern
await _storage.SaveArticleSentimentAsync(article, finbert); await _storage.SaveArticleSentimentAsync(article, finbert);
// 2. ISIN- & Sektor-Summaries aktualisieren
if (article.MatchedAssets != null && article.MatchedAssets.Count > 0) if (article.MatchedAssets != null && article.MatchedAssets.Count > 0)
{ {
foreach (var asset in article.MatchedAssets) foreach (var asset in article.MatchedAssets)
@@ -187,29 +163,22 @@ public class SentimentBackgroundService : BackgroundService
if (cancellationToken.IsCancellationRequested) return; if (cancellationToken.IsCancellationRequested) return;
// 3. FinlyticNews über Erfolg informieren
bool updated = await _mqttClient.UpdateArticleStatusAsync(article.Id, "Analyzed"); bool updated = await _mqttClient.UpdateArticleStatusAsync(article.Id, "Analyzed");
if (updated) if (updated)
{ {
_logger.LogInformation( 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);
"[{Channel}] Article sentiment processed and status set to 'Analyzed' in FinlyticNews: {Title} (ID: {Id}) -> {Label}",
"SentimentChannel", article.Title, article.Id, finbert.Label);
} }
else else
{ {
_logger.LogWarning( await _finlyticLogger.LogWarningAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] Failed to confirm status update to 'Analyzed' in FinlyticNews for article: {Id}", article.Id);
"[{Channel}] Failed to confirm status update to 'Analyzed' in FinlyticNews for article: {Id}",
"SentimentChannel", article.Id);
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "[{Channel}] Error processing sentiment for article: {Id} ({Title})", await _finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[SentimentBackgroundService] Error processing sentiment for article: {Id} ({Title})", article.Id, article.Title);
"SentimentChannel", article.Id, article.Title);
} }
finally finally
{ {
// Lock nach der Verarbeitung immer freigeben
ProcessingArticles.TryRemove(article.Id, out _); ProcessingArticles.TryRemove(article.Id, out _);
} }
} }
@@ -1,10 +1,17 @@
using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Encodings.Web; using System.Text.Encodings.Web;
using System.Text.Json; using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.News; using FinlyticCore.Dtos.News;
using FinlyticCore.Dtos.Sentiment; using FinlyticCore.Dtos.Sentiment;
using FinlyticCore.Services;
using FinlyticSentiment.Util;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace FinlyticSentiment.Services; namespace FinlyticSentiment.Services;
@@ -13,40 +20,10 @@ namespace FinlyticSentiment.Services;
/// </summary> /// </summary>
public interface ISentimentStorageService public interface ISentimentStorageService
{ {
/// <summary>
/// Appends a new FinBERT analysis event to the ISIN summary file and recalculates the current aggregate metrics.
/// </summary>
/// <param name="isin">The asset ISIN code.</param>
/// <param name="companyName">The name of the asset company.</param>
/// <param name="sector">The sector associated with the asset.</param>
/// <param name="article">The analyzed article DTO.</param>
/// <param name="finbert">The FinBERT sentiment result.</param>
/// <returns>A task representing the file update operation.</returns>
Task UpdateIsinSummaryAsync(string isin, string companyName, string sector, NewsArticleDto article, FinBertResultDto finbert); Task UpdateIsinSummaryAsync(string isin, string companyName, string sector, NewsArticleDto article, FinBertResultDto finbert);
/// <summary>
/// Appends a new FinBERT analysis event to the Sector summary file and recalculates the sector aggregate metrics.
/// </summary>
/// <param name="sector">The target sector name.</param>
/// <param name="isin">The asset ISIN code triggering the sector update.</param>
/// <param name="articleId">The unique news article identifier.</param>
/// <param name="finbert">The FinBERT sentiment result.</param>
/// <returns>A task representing the file update operation.</returns>
Task UpdateSectorSummaryAsync(string sector, string isin, string articleId, FinBertResultDto finbert); Task UpdateSectorSummaryAsync(string sector, string isin, string articleId, FinBertResultDto finbert);
/// <summary>
/// Persists an article's sentiment analysis directly in data/summaries/articles/{articleId}.json.
/// </summary>
Task SaveArticleSentimentAsync(NewsArticleDto article, FinBertResultDto finbert); Task SaveArticleSentimentAsync(NewsArticleDto article, FinBertResultDto finbert);
/// <summary>
/// Retrieves the sentiment analysis entry for a specific news article.
/// </summary>
Task<IsinAnalysisEntry?> GetArticleSentimentAsync(string articleId); Task<IsinAnalysisEntry?> GetArticleSentimentAsync(string articleId);
/// <summary>
/// Retrieves the aggregate sentiment summary for a specific asset ISIN.
/// </summary>
Task<IsinSentimentSummaryDto?> GetIsinSummaryAsync(string isin); Task<IsinSentimentSummaryDto?> GetIsinSummaryAsync(string isin);
} }
@@ -54,13 +31,13 @@ public class SentimentStorageService : ISentimentStorageService
{ {
private static readonly ConcurrentDictionary<string, SemaphoreSlim> FileLocks = new(); private static readonly ConcurrentDictionary<string, SemaphoreSlim> FileLocks = new();
private readonly ILogger<SentimentStorageService> _logger; private readonly IFinlyticLogger<SentimentStorageService> _finlyticLogger;
private readonly string _basePath; private readonly string _basePath;
private readonly JsonSerializerOptions _jsonOptions; private readonly JsonSerializerOptions _jsonOptions;
public SentimentStorageService(IConfiguration configuration, ILogger<SentimentStorageService> logger) public SentimentStorageService(IConfiguration configuration, IFinlyticLogger<SentimentStorageService> finlyticLogger)
{ {
_logger = logger; _finlyticLogger = finlyticLogger;
_basePath = configuration["Storage:SummariesPath"] ?? "data/summaries"; _basePath = configuration["Storage:SummariesPath"] ?? "data/summaries";
Directory.CreateDirectory(Path.Combine(_basePath, "isin")); Directory.CreateDirectory(Path.Combine(_basePath, "isin"));
@@ -88,30 +65,28 @@ public class SentimentStorageService : ISentimentStorageService
try try
{ {
string nowIso = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"); string nowIso = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ");
string analysisId = $"sent_{DateTime.UtcNow:yyyyMMdd}_{Random.Shared.Next(100, 999)}";
var entry = new IsinAnalysisEntry var entry = new IsinAnalysisEntry
{ {
AnalysisId = analysisId, AnalysisId = $"sent_{Guid.NewGuid():N}",
Timestamp = nowIso, Timestamp = nowIso,
Article = new IsinAnalysisArticleRef Article = new IsinAnalysisArticleRef
{ {
ArticleId = cleanId, ArticleId = cleanId,
Title = article.Title ?? "", Title = article.Title,
Source = article.Author ?? "FinlyticNews", PublishedAt = article.PublishedAt.ToString("yyyy-MM-ddTHH:mm:ssZ"),
PublishedAt = article.PublishedAt.ToString("yyyy-MM-ddTHH:mm:ssZ") Source = article.Author ?? "FinlyticNews"
}, },
FinbertResult = finbert, FinbertResult = finbert,
SummarySnippet = finbert.SummarySnippet ?? article.Summary ?? article.Title ?? "" SummarySnippet = finbert.SummarySnippet ?? article.Summary ?? string.Empty
}; };
string json = JsonSerializer.Serialize(entry, _jsonOptions); var json = JsonSerializer.Serialize(entry, _jsonOptions);
await File.WriteAllTextAsync(filePath, json); await File.WriteAllTextAsync(filePath, json);
_logger.LogInformation("[{Channel}] Successfully saved article sentiment file: {Path}", "SentimentChannel", filePath); await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentStorageService] Successfully saved article sentiment file: {Path}", filePath);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "[{Channel}] Failed to write article sentiment file: {Path}", "SentimentChannel", filePath); await _finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[SentimentStorageService] Failed to write article sentiment file: {Path}", filePath);
} }
finally finally
{ {
@@ -124,10 +99,9 @@ public class SentimentStorageService : ISentimentStorageService
{ {
if (string.IsNullOrWhiteSpace(articleId)) return null; if (string.IsNullOrWhiteSpace(articleId)) return null;
var cleanId = articleId.Trim(); string cleanId = articleId.Trim();
var articleFilePath = Path.Combine(_basePath, "articles", $"{cleanId}.json"); string articleFilePath = Path.Combine(_basePath, "articles", $"{cleanId}.json");
// 1. Primärer Lookup
if (File.Exists(articleFilePath)) if (File.Exists(articleFilePath))
{ {
var fileLock = FileLocks.GetOrAdd(articleFilePath, _ => new SemaphoreSlim(1, 1)); var fileLock = FileLocks.GetOrAdd(articleFilePath, _ => new SemaphoreSlim(1, 1));
@@ -139,7 +113,7 @@ public class SentimentStorageService : ISentimentStorageService
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "[{Channel}] Failed to read article sentiment file: {Path}", "SentimentChannel", articleFilePath); await _finlyticLogger.LogWarningAsync(SettingKeys.SentimentChannel, ex, "[SentimentStorageService] Failed to read article sentiment file: {Path}", articleFilePath);
} }
finally finally
{ {
@@ -147,22 +121,6 @@ public class SentimentStorageService : ISentimentStorageService
} }
} }
// 2. Fallback in ISIN-Dateien
var dirPath = Path.Combine(_basePath, "isin");
if (!Directory.Exists(dirPath)) return null;
foreach (var file in Directory.GetFiles(dirPath, "*.json"))
{
try
{
var json = await File.ReadAllTextAsync(file);
var doc = JsonSerializer.Deserialize<IsinSentimentSummaryDto>(json, _jsonOptions);
var match = doc?.Analyses?.FirstOrDefault(a => string.Equals(a.Article?.ArticleId?.Trim(), cleanId, StringComparison.OrdinalIgnoreCase));
if (match != null) return match;
}
catch { }
}
return null; return null;
} }
@@ -171,8 +129,8 @@ public class SentimentStorageService : ISentimentStorageService
{ {
if (string.IsNullOrWhiteSpace(isin)) return null; if (string.IsNullOrWhiteSpace(isin)) return null;
var cleanIsin = isin.Trim(); string cleanIsin = isin.Trim().ToUpperInvariant();
var filePath = Path.Combine(_basePath, "isin", $"{cleanIsin}.json"); string filePath = Path.Combine(_basePath, "isin", $"{cleanIsin}.json");
if (!File.Exists(filePath)) return null; if (!File.Exists(filePath)) return null;
@@ -186,7 +144,7 @@ public class SentimentStorageService : ISentimentStorageService
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "[{Channel}] Error reading ISIN summary file: {Path}", "SentimentChannel", filePath); await _finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[SentimentStorageService] Error reading ISIN summary file: {Path}", filePath);
return null; return null;
} }
finally finally
@@ -198,9 +156,9 @@ public class SentimentStorageService : ISentimentStorageService
/// <inheritdoc /> /// <inheritdoc />
public async Task UpdateIsinSummaryAsync(string isin, string companyName, string sector, NewsArticleDto article, FinBertResultDto finbert) public async Task UpdateIsinSummaryAsync(string isin, string companyName, string sector, NewsArticleDto article, FinBertResultDto finbert)
{ {
if (string.IsNullOrWhiteSpace(isin) || article == null) return; if (string.IsNullOrWhiteSpace(isin)) return;
string cleanIsin = isin.Trim(); string cleanIsin = isin.Trim().ToUpperInvariant();
string filePath = Path.Combine(_basePath, "isin", $"{cleanIsin}.json"); string filePath = Path.Combine(_basePath, "isin", $"{cleanIsin}.json");
var fileLock = FileLocks.GetOrAdd(filePath, _ => new SemaphoreSlim(1, 1)); var fileLock = FileLocks.GetOrAdd(filePath, _ => new SemaphoreSlim(1, 1));
@@ -208,87 +166,92 @@ public class SentimentStorageService : ISentimentStorageService
try try
{ {
IsinSentimentSummaryDto isinDoc; var analyses = new List<IsinAnalysisEntry>();
if (File.Exists(filePath)) if (File.Exists(filePath))
{ {
try var existingJson = await File.ReadAllTextAsync(filePath);
var existing = JsonSerializer.Deserialize<IsinSentimentSummaryDto>(existingJson, _jsonOptions);
if (existing?.Analyses != null)
{ {
string json = await File.ReadAllTextAsync(filePath); analyses.AddRange(existing.Analyses);
isinDoc = JsonSerializer.Deserialize<IsinSentimentSummaryDto>(json, _jsonOptions) ?? new IsinSentimentSummaryDto { Isin = cleanIsin };
}
catch
{
isinDoc = new IsinSentimentSummaryDto { Isin = cleanIsin };
} }
} }
else
{ string cleanArticleId = article.Id.ToString();
isinDoc = new IsinSentimentSummaryDto { Isin = cleanIsin }; analyses.RemoveAll(a => string.Equals(a.Article?.ArticleId, cleanArticleId, StringComparison.OrdinalIgnoreCase));
}
string nowIso = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"); string nowIso = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ");
string analysisId = $"sent_{DateTime.UtcNow:yyyyMMdd}_{Random.Shared.Next(100, 999)}";
var newEntry = new IsinAnalysisEntry var newEntry = new IsinAnalysisEntry
{ {
AnalysisId = analysisId, AnalysisId = $"sent_{Guid.NewGuid():N}",
Timestamp = nowIso, Timestamp = nowIso,
Article = new IsinAnalysisArticleRef Article = new IsinAnalysisArticleRef
{ {
ArticleId = article.Id.ToString(), ArticleId = cleanArticleId,
Title = article.Title ?? "", Title = article.Title,
Source = article.Author ?? "FinlyticNews", PublishedAt = article.PublishedAt.ToString("yyyy-MM-ddTHH:mm:ssZ"),
PublishedAt = article.PublishedAt.ToString("yyyy-MM-ddTHH:mm:ssZ") Source = article.Author ?? "FinlyticNews"
}, },
FinbertResult = finbert, FinbertResult = finbert,
SummarySnippet = finbert.SummarySnippet ?? article.Summary ?? article.Title ?? "" SummarySnippet = finbert.SummarySnippet ?? article.Summary ?? string.Empty
}; };
// Duplikat-Bereinigung: Falls Artikel bereits existiert, alten Eintrag entfernen! analyses.Add(newEntry);
var updatedAnalyses = isinDoc.Analyses?
.Where(a => !string.Equals(a.Article?.ArticleId, article.Id.ToString(), StringComparison.OrdinalIgnoreCase))
.ToList() ?? new List<IsinAnalysisEntry>();
// Neuen Eintrag oben einfügen var cutoff = DateTime.UtcNow.AddDays(-14);
updatedAnalyses.Insert(0, newEntry); var validAnalyses = analyses.Where(a =>
// Capping: Maximal die letzten 100 Analysen aufheben (verhindert gigantische JSON-Dateien)
if (updatedAnalyses.Count > 100)
{ {
updatedAnalyses = updatedAnalyses.Take(100).ToList(); if (DateTime.TryParse(a.Article?.PublishedAt ?? a.Timestamp, out var pubDate))
{
return pubDate >= cutoff;
}
return true;
}).ToList();
var updatedAnalyses = validAnalyses.OrderByDescending(a => a.Article?.PublishedAt ?? a.Timestamp).Take(50).ToList();
double totalCompound = 0.0;
double totalConf = 0.0;
foreach (var item in updatedAnalyses)
{
if (item.FinbertResult == null) continue;
totalCompound += item.FinbertResult.CompoundScore;
totalConf += item.FinbertResult.Confidence;
} }
double avgCompound = updatedAnalyses.Average(a => a.FinbertResult.CompoundScore); int total = updatedAnalyses.Count;
double avgConf = updatedAnalyses.Average(a => a.FinbertResult.Confidence); double avgCompound = total > 0 ? totalCompound / total : 0.0;
string label = CalculateLabel(avgCompound); double avgConf = total > 0 ? totalConf / total : 0.0;
string textSummary = label switch string overallLabel = "NEUTRAL";
{ if (avgCompound >= 0.15) overallLabel = "POSITIVE";
"POSITIVE" => $"Die Stimmungsanalyse zeigt einen weiterhin positiven Trend ({avgCompound:F2}). Hauptursache sind positive Berichte und starke Markt-Signale.", else if (avgCompound <= -0.15) overallLabel = "NEGATIVE";
"NEGATIVE" => $"Die Stimmungsanalyse deutet auf einen verhaltenen bis negativen Trend hin ({avgCompound:F2}). Auf kritische Markt-Berichte sollte geachtet werden.",
_ => $"Das Gesamtsentiment ist neutral ({avgCompound:F2}). Ausgewogene Signale aus der aktuellen Berichterstattung."
};
var updatedDoc = isinDoc with var summary = new IsinSentimentSummaryDto
{ {
Isin = cleanIsin, Isin = cleanIsin,
CompanyName = !string.IsNullOrWhiteSpace(companyName) ? companyName : isinDoc.CompanyName, CompanyName = companyName,
Sector = !string.IsNullOrWhiteSpace(sector) ? sector : isinDoc.Sector, Sector = sector,
LastUpdated = nowIso, LastUpdated = nowIso,
CurrentSummary = new IsinCurrentSummary CurrentSummary = new IsinCurrentSummary
{ {
CompoundScore = Math.Round(avgCompound, 2), CompoundScore = Math.Round(avgCompound, 4),
SentimentLabel = label, SentimentLabel = overallLabel,
AvgConfidence = Math.Round(avgConf, 2), AvgConfidence = Math.Round(avgConf, 4),
TotalArticlesAnalyzed = updatedAnalyses.Count, TotalArticlesAnalyzed = total,
Text = textSummary Text = $"Synthesized sentiment across {total} articles is {overallLabel}."
}, },
Analyses = updatedAnalyses Analyses = updatedAnalyses
}; };
await File.WriteAllTextAsync(filePath, JsonSerializer.Serialize(updatedDoc, _jsonOptions)); var outJson = JsonSerializer.Serialize(summary, _jsonOptions);
_logger.LogInformation("[{Channel}] Updated ISIN summary file: {Path} (Total: {Count}, Score: {Score:F2})", "SentimentChannel", filePath, updatedAnalyses.Count, avgCompound); await File.WriteAllTextAsync(filePath, outJson);
await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentStorageService] Updated ISIN summary file: {Path} (Total: {Count}, Score: {Score:F2})", filePath, updatedAnalyses.Count, avgCompound);
}
catch (Exception ex)
{
await _finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[SentimentStorageService] Failed to update ISIN summary for {Isin}", cleanIsin);
} }
finally finally
{ {
@@ -301,95 +264,82 @@ public class SentimentStorageService : ISentimentStorageService
{ {
if (string.IsNullOrWhiteSpace(sector)) return; if (string.IsNullOrWhiteSpace(sector)) return;
string sanitizedSector = string.Concat(sector.Split(Path.GetInvalidFileNameChars())).Trim(); string cleanSector = sector.Trim().ToLowerInvariant();
string filePath = Path.Combine(_basePath, "sectors", $"{sanitizedSector}.json"); string filePath = Path.Combine(_basePath, "sectors", $"{cleanSector}.json");
var fileLock = FileLocks.GetOrAdd(filePath, _ => new SemaphoreSlim(1, 1)); var fileLock = FileLocks.GetOrAdd(filePath, _ => new SemaphoreSlim(1, 1));
await fileLock.WaitAsync(); await fileLock.WaitAsync();
try try
{ {
SectorSentimentSummaryDto sectorDoc; var analyses = new List<SectorAnalysisEntry>();
if (File.Exists(filePath)) if (File.Exists(filePath))
{ {
try var existingJson = await File.ReadAllTextAsync(filePath);
var existing = JsonSerializer.Deserialize<SectorSentimentSummaryDto>(existingJson, _jsonOptions);
if (existing?.Analyses != null)
{ {
string json = await File.ReadAllTextAsync(filePath); analyses.AddRange(existing.Analyses);
sectorDoc = JsonSerializer.Deserialize<SectorSentimentSummaryDto>(json, _jsonOptions) ?? new SectorSentimentSummaryDto { Sector = sector };
} }
catch
{
sectorDoc = new SectorSentimentSummaryDto { Sector = sector };
}
}
else
{
sectorDoc = new SectorSentimentSummaryDto { Sector = sector };
} }
string cleanIsin = isin.Trim().ToUpperInvariant();
string nowIso = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"); string nowIso = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ");
string analysisId = $"sec_sent_{DateTime.UtcNow:yyyyMMdd}_{Random.Shared.Next(100, 999)}";
var newEntry = new SectorAnalysisEntry analyses.RemoveAll(a => string.Equals(a.ArticleId, articleId, StringComparison.OrdinalIgnoreCase) && string.Equals(a.RelatedIsin, cleanIsin, StringComparison.OrdinalIgnoreCase));
analyses.Add(new SectorAnalysisEntry
{ {
AnalysisId = analysisId, AnalysisId = $"sec_{Guid.NewGuid():N}",
Timestamp = nowIso, Timestamp = nowIso,
RelatedIsin = isin, RelatedIsin = cleanIsin,
ArticleId = articleId, ArticleId = articleId,
FinbertResult = finbert FinbertResult = finbert
}; });
// Duplikate bereinigen (selber Artikel für denselben Sektor) var cutoff = DateTime.UtcNow.AddDays(-14);
var updatedAnalyses = sectorDoc.Analyses? var updatedAnalyses = analyses.Where(a =>
.Where(a => !string.Equals(a.ArticleId, articleId, StringComparison.OrdinalIgnoreCase))
.ToList() ?? new List<SectorAnalysisEntry>();
updatedAnalyses.Insert(0, newEntry);
if (updatedAnalyses.Count > 100)
{ {
updatedAnalyses = updatedAnalyses.Take(100).ToList(); if (DateTime.TryParse(a.Timestamp, out var ts))
} {
return ts >= cutoff;
}
return true;
}).OrderByDescending(a => a.Timestamp).Take(100).ToList();
var activeIsins = updatedAnalyses.Select(a => a.RelatedIsin).Where(i => !string.IsNullOrWhiteSpace(i)).Distinct().ToList(); var activeIsins = updatedAnalyses.Select(a => a.RelatedIsin).Where(i => !string.IsNullOrEmpty(i)).Distinct().ToList();
double avgCompound = updatedAnalyses.Average(a => a.FinbertResult.CompoundScore); double totalSectorCompound = updatedAnalyses.Sum(s => s.FinbertResult.CompoundScore);
string label = CalculateLabel(avgCompound); double avgSectorCompound = updatedAnalyses.Count > 0 ? totalSectorCompound / updatedAnalyses.Count : 0.0;
string overviewText = label switch string sectorLabel = "NEUTRAL";
{ if (avgSectorCompound >= 0.15) sectorLabel = "POSITIVE";
"POSITIVE" => $"Der Sektor {sector} tendiert insgesamt positiv. Starke Einzelergebnisse stützen den Trend.", else if (avgSectorCompound <= -0.15) sectorLabel = "NEGATIVE";
"NEGATIVE" => $"Der Sektor {sector} verzeichnet dämpfende Sentiment-Signale.",
_ => $"Der Sektor {sector} zeigt ein ausgewogenes neutrales Gesamtbild."
};
var updatedDoc = sectorDoc with var summary = new SectorSentimentSummaryDto
{ {
Sector = sector, Sector = sector,
LastUpdated = nowIso, LastUpdated = nowIso,
CurrentSummary = new SectorCurrentSummary CurrentSummary = new SectorCurrentSummary
{ {
CompoundScore = Math.Round(avgCompound, 2), CompoundScore = Math.Round(avgSectorCompound, 4),
SentimentLabel = label, SentimentLabel = sectorLabel,
ActiveIsins = activeIsins, ActiveIsins = activeIsins,
Text = overviewText Text = $"Sector {sector} aggregate sentiment: {sectorLabel}."
}, },
Analyses = updatedAnalyses Analyses = updatedAnalyses
}; };
await File.WriteAllTextAsync(filePath, JsonSerializer.Serialize(updatedDoc, _jsonOptions)); var outJson = JsonSerializer.Serialize(summary, _jsonOptions);
_logger.LogInformation("[{Channel}] Updated Sector summary file: {Path} (Active ISINs: {Count})", "SentimentChannel", filePath, activeIsins.Count); await File.WriteAllTextAsync(filePath, outJson);
await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentStorageService] Updated Sector summary file: {Path} (Active ISINs: {Count})", filePath, activeIsins.Count);
}
catch (Exception ex)
{
await _finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[SentimentStorageService] Failed to update Sector summary for {Sector}", sector);
} }
finally finally
{ {
fileLock.Release(); fileLock.Release();
} }
} }
private static string CalculateLabel(double score) => score switch
{
>= 0.15 => "POSITIVE",
<= -0.15 => "NEGATIVE",
_ => "NEUTRAL"
};
} }
+141 -146
View File
@@ -1,10 +1,17 @@
using System;
using System.Collections.Generic;
using System.Text.Json; using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos; using FinlyticCore.Dtos;
using FinlyticCore.Dtos.News; using FinlyticCore.Dtos.News;
using FinlyticCore.Dtos.Sentiment; using FinlyticCore.Dtos.Sentiment;
using FinlyticCore.Dtos.Settings;
using FinlyticCore.Models; using FinlyticCore.Models;
using FinlyticCore.Services;
using FinlyticCore.Util; using FinlyticCore.Util;
using FinlyticSentiment.Services; using FinlyticSentiment.Services;
using FinlyticSentiment.Util;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
@@ -21,9 +28,6 @@ public class SentimentMqttClient : ManagedMqttClient, IHostedService
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly IServiceScopeFactory _scopeFactory; private readonly IServiceScopeFactory _scopeFactory;
/// <summary>
/// Initializes a new instance of the <see cref="SentimentMqttClient"/> class.
/// </summary>
public SentimentMqttClient( public SentimentMqttClient(
ILogger<SentimentMqttClient> logger, ILogger<SentimentMqttClient> logger,
IConfiguration configuration, IConfiguration configuration,
@@ -43,12 +47,10 @@ public class SentimentMqttClient : ManagedMqttClient, IHostedService
{ {
Host = _configuration["MQTT:Host"] ?? _configuration["MQTT__Host"] ?? "localhost", Host = _configuration["MQTT:Host"] ?? _configuration["MQTT__Host"] ?? "localhost",
Port = Convert.ToInt32(_configuration["MQTT:Port"] ?? _configuration["MQTT__Port"] ?? "1883"), Port = Convert.ToInt32(_configuration["MQTT:Port"] ?? _configuration["MQTT__Port"] ?? "1883"),
ClientId = ClientId = $"{(_configuration["MQTT:ClientId"] ?? _configuration["MQTT__ClientId"] ?? "FinlyticSentiment")}_{Guid.NewGuid()}"
$"{(_configuration["MQTT:ClientId"] ?? _configuration["MQTT__ClientId"] ?? "FinlyticSentiment")}_{Guid.NewGuid()}"
}; };
_logger.LogInformation("[{Channel}] Starting Sentiment MQTT client. Host: {Host}, ClientId: {ClientId}", _logger.LogInformation("Starting Sentiment MQTT client. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId);
"SentimentChannel", config.Host, config.ClientId);
await ConnectAsync(config); await ConnectAsync(config);
} }
@@ -57,7 +59,7 @@ public class SentimentMqttClient : ManagedMqttClient, IHostedService
/// </summary> /// </summary>
public async Task StopAsync(CancellationToken cancellationToken) public async Task StopAsync(CancellationToken cancellationToken)
{ {
_logger.LogInformation("[{Channel}] Stopping Sentiment MQTT client and disconnecting.", "SentimentChannel"); _logger.LogInformation("Stopping Sentiment MQTT client and disconnecting.");
await DisconnectAsync(); await DisconnectAsync();
} }
@@ -69,16 +71,24 @@ public class SentimentMqttClient : ManagedMqttClient, IHostedService
/// <inheritdoc /> /// <inheritdoc />
protected override async Task OnConnectedAsync() protected override async Task OnConnectedAsync()
{ {
_logger.LogInformation( _logger.LogInformation("Sentiment MQTT client connected. Subscribing to RPC response and broadcast topics...");
"[{Channel}] Sentiment MQTT client connected. Subscribing to RPC response and broadcast topics...",
"SentimentChannel");
await SubscribeAsync("services/response/#"); await SubscribeAsync("services/response/#");
await SubscribeAsync("services/news/completed"); await SubscribeAsync("services/news/completed");
await SubscribeAsync("services/request/sentiment_GetArticle/#"); await SubscribeAsync("services/request/sentiment_GetArticle/#");
await SubscribeAsync("services/request/sentiment_GetIsin/#"); await SubscribeAsync("services/request/sentiment_GetIsin/#");
await SubscribeAsync("services/request/sentiment_Analyze/#"); await SubscribeAsync("services/request/sentiment_Analyze/#");
await SubscribeAsync("services/request/sentiment_settings_GetAll/#");
await SubscribeAsync("services/request/sentiment_settings_Update/#");
await SubscribeAsync("services/request/health_Ping/#"); await SubscribeAsync("services/request/health_Ping/#");
await SubscribeAsync("services/config/updated/#"); await SubscribeAsync("services/config/updated/#");
FinlyticCore.Services.FinlyticLogBroadcaster.OnLogPublished = async (logDto) =>
{
if (IsConnected && string.Equals(logDto.ServiceName, "FinlyticSentiment", StringComparison.OrdinalIgnoreCase))
{
await PublishAsync("finlytic/logs/FinlyticSentiment", logDto);
}
};
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -86,14 +96,12 @@ public class SentimentMqttClient : ManagedMqttClient, IHostedService
{ {
if (string.IsNullOrWhiteSpace(topic)) return; if (string.IsNullOrWhiteSpace(topic)) return;
// 1. Config update events
if (topic.StartsWith("services/config/updated", StringComparison.OrdinalIgnoreCase)) if (topic.StartsWith("services/config/updated", StringComparison.OrdinalIgnoreCase))
{ {
if (topic.EndsWith("FinlyticSentiment", StringComparison.OrdinalIgnoreCase)) if (topic.EndsWith("FinlyticSentiment", StringComparison.OrdinalIgnoreCase))
{ {
await OnConfigUpdatedAsync(payload); await OnConfigUpdatedAsync(payload);
} }
return; return;
} }
@@ -103,13 +111,11 @@ public class SentimentMqttClient : ManagedMqttClient, IHostedService
return; return;
} }
// Extract correlationId from topic suffix (e.g. services/request/sentiment_GetArticle/{correlationId})
var lastSlash = topic.LastIndexOf('/'); var lastSlash = topic.LastIndexOf('/');
if (lastSlash < 0 || lastSlash >= topic.Length - 1) return; if (lastSlash < 0 || lastSlash >= topic.Length - 1) return;
var correlationId = topic.Substring(lastSlash + 1); var correlationId = topic.Substring(lastSlash + 1);
// 2. Dispatch to specific channel handlers
if (topic.StartsWith("services/request/sentiment_GetArticle", StringComparison.OrdinalIgnoreCase)) if (topic.StartsWith("services/request/sentiment_GetArticle", StringComparison.OrdinalIgnoreCase))
{ {
await OnSentimentGetArticleAsync(payload, correlationId); await OnSentimentGetArticleAsync(payload, correlationId);
@@ -122,178 +128,205 @@ public class SentimentMqttClient : ManagedMqttClient, IHostedService
{ {
await OnSentimentAnalyzeAsync(payload, correlationId); await OnSentimentAnalyzeAsync(payload, correlationId);
} }
else if (topic.StartsWith("services/request/sentiment_settings_GetAll", StringComparison.OrdinalIgnoreCase))
{
await OnSettingsGetAllAsync(correlationId);
}
else if (topic.StartsWith("services/request/sentiment_settings_Update", StringComparison.OrdinalIgnoreCase))
{
await OnSettingsUpdateAsync(payload, correlationId);
}
else if (topic.StartsWith("services/request/health_Ping", StringComparison.OrdinalIgnoreCase)) else if (topic.StartsWith("services/request/health_Ping", StringComparison.OrdinalIgnoreCase))
{ {
await OnHealthPingAsync(topic, correlationId); await OnHealthPingAsync(topic, correlationId);
} }
} }
/// <summary> private async Task OnSettingsGetAllAsync(string correlationId)
/// Handles dynamic service config update events. {
/// </summary> using var scope = _scopeFactory.CreateScope();
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<SentimentMqttClient>>();
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticSentiment] [Settings_GetAll] Retrieving all dynamic settings via reflection [CorrelationId: {CorrelationId}]", correlationId);
try
{
var settings = await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SettingKeys) });
var responseTopic = $"services/response/sentiment_settings_GetAll/{correlationId}";
await PublishAsync(responseTopic, settings);
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticSentiment] [Settings_GetAll] Published {Count} settings to '{ResponseTopic}'", settings.Count, responseTopic);
}
catch (Exception ex)
{
await finlyticLogger.LogErrorAsync(SettingKeys.MqttChannel, ex, "[FinlyticSentiment] [Settings_GetAll] Failed to retrieve settings.");
}
}
private async Task OnSettingsUpdateAsync(string payload, string correlationId)
{
if (string.IsNullOrWhiteSpace(payload)) return;
using var scope = _scopeFactory.CreateScope();
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<SentimentMqttClient>>();
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticSentiment] [Settings_Update] Processing settings update RPC [CorrelationId: {CorrelationId}]", correlationId);
try
{
Dictionary<string, object?>? updates = null;
try
{
updates = JsonSerializer.Deserialize<Dictionary<string, object?>>(payload);
}
catch
{
var list = JsonSerializer.Deserialize<List<DynamicSettingDto>>(payload);
if (list != null)
{
updates = new Dictionary<string, object?>();
foreach (var item in list) updates[item.Key] = item.Value;
}
}
if (updates != null && updates.Count > 0)
{
await settingsService.UpdateSettingsAsync(updates);
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticSentiment] [Settings_Update] Successfully updated {Count} settings in database and cache.", updates.Count);
}
var currentSettings = await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SettingKeys) });
var responseTopic = $"services/response/sentiment_settings_Update/{correlationId}";
await PublishAsync(responseTopic, currentSettings);
}
catch (Exception ex)
{
await finlyticLogger.LogErrorAsync(SettingKeys.MqttChannel, ex, "[FinlyticSentiment] [Settings_Update] Failed to update settings.");
}
}
private async Task OnConfigUpdatedAsync(string payload) private async Task OnConfigUpdatedAsync(string payload)
{ {
_logger.LogInformation("[{Channel}] [SentimentMqttClient] Received config update event for FinlyticSentiment.",
"SentimentChannel");
try try
{ {
using var doc = JsonDocument.Parse(payload); using var doc = JsonDocument.Parse(payload);
if (doc.RootElement.TryGetProperty("settings", out var settingsProp)) if (doc.RootElement.TryGetProperty("settings", out var settingsProp))
{ {
var dict = JsonSerializer.Deserialize(settingsProp.GetRawText(), typeof(Dictionary<string, string>), var dict = JsonSerializer.Deserialize<Dictionary<string, object?>>(settingsProp.GetRawText());
FinlyticJsonSerializerContext.Default) as Dictionary<string, string>;
if (dict != null && dict.Count > 0) if (dict != null && dict.Count > 0)
{ {
using var scope = _scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var settingsDb = scope.ServiceProvider.GetRequiredService<ISettingsDbService>(); var settings = scope.ServiceProvider.GetRequiredService<ISettingsService>();
await settingsDb.UpdateSettingsFromDictionaryAsync(dict); await settings.UpdateSettingsAsync(dict);
_logger.LogInformation(
"[{Channel}] [SentimentMqttClient] Successfully persisted {Count} updated settings for FinlyticSentiment.",
"SentimentChannel", dict.Count);
} }
} }
} }
catch (Exception ex) catch { }
{
_logger.LogError(ex, "[{Channel}] [SentimentMqttClient] Error processing MQTT config update event.",
"SentimentChannel");
}
} }
/// <summary>
/// Handles health_Ping RPC requests.
/// </summary>
private async Task OnHealthPingAsync(string topic, string correlationId) private async Task OnHealthPingAsync(string topic, string correlationId)
{ {
if (topic.Contains("FinlyticSentiment", StringComparison.OrdinalIgnoreCase) || if (topic.Contains("FinlyticSentiment", StringComparison.OrdinalIgnoreCase) ||
!topic.Contains("/", StringComparison.OrdinalIgnoreCase)) !topic.Contains("/", StringComparison.OrdinalIgnoreCase))
{ {
string respTopic = $"services/response/health_Ping/{correlationId}"; string respTopic = $"services/response/health_Ping/{correlationId}";
await PublishAsync(respTopic, await PublishAsync(respTopic, new ServiceHealthResponse("FinlyticSentiment", "Online", DateTime.UtcNow, "Connected"));
new ServiceHealthResponse("FinlyticSentiment", "Online", DateTime.UtcNow, "Connected"));
_logger.LogInformation( using var scope = _scopeFactory.CreateScope();
"[{Channel}] [SentimentMqttClient] Responded to live health_Ping RPC request [CorrelationId: {CorrelationId}].", var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<SentimentMqttClient>>();
"SentimentChannel", correlationId); await finlyticLogger.LogInfoAsync(SettingKeys.HealthPingChannel, "[FinlyticSentiment] Responded to live health_Ping RPC request [CorrelationId: {CorrelationId}].", correlationId);
} }
} }
/// <summary>
/// Handles broadcasted articles on services/news/completed.
/// </summary>
private async Task OnNewsCompletedAsync(string payload) private async Task OnNewsCompletedAsync(string payload)
{ {
if (string.IsNullOrWhiteSpace(payload)) return; if (string.IsNullOrWhiteSpace(payload)) return;
try try
{ {
var article = var article = JsonSerializer.Deserialize(payload, typeof(NewsArticleDto), FinlyticJsonSerializerContext.Default) as NewsArticleDto;
JsonSerializer.Deserialize(payload, typeof(NewsArticleDto), FinlyticJsonSerializerContext.Default) as
NewsArticleDto;
if (article != null && article.Id != Guid.Empty && OnArticleReceived != null) if (article != null && article.Id != Guid.Empty && OnArticleReceived != null)
{ {
_logger.LogInformation( using var scope = _scopeFactory.CreateScope();
"[{Channel}] Received real-time article broadcast on services/news/completed: {Title} (ID: {Id})", var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<SentimentMqttClient>>();
"SentimentChannel", article.Title, article.Id); await finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "Received real-time article broadcast on services/news/completed: {Title} (ID: {Id})", article.Title, article.Id);
await OnArticleReceived.Invoke(article); await OnArticleReceived.Invoke(article);
} }
} }
catch (Exception ex) catch { }
{
_logger.LogError(ex, "[{Channel}] Error parsing broadcasted article on services/news/completed.",
"SentimentChannel");
}
} }
/// <summary>
/// Handles sentiment_GetArticle RPC requests using source-generated DTO deserialization.
/// </summary>
private async Task OnSentimentGetArticleAsync(string payload, string correlationId) private async Task OnSentimentGetArticleAsync(string payload, string correlationId)
{ {
_logger.LogInformation(
"[{Channel}] [SentimentMqttClient] Processing RPC sentiment_GetArticle request [CorrelationId: {CorrelationId}]",
"SentimentChannel", correlationId);
if (string.IsNullOrWhiteSpace(payload)) return; if (string.IsNullOrWhiteSpace(payload)) return;
using var scope = _scopeFactory.CreateScope();
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<SentimentMqttClient>>();
await finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentMqttClient] Processing RPC sentiment_GetArticle request [CorrelationId: {CorrelationId}]", correlationId);
try try
{ {
var request = var request = JsonSerializer.Deserialize(payload, typeof(ArticleRequest), FinlyticJsonSerializerContext.Default) as ArticleRequest;
JsonSerializer.Deserialize(payload, typeof(ArticleRequest), FinlyticJsonSerializerContext.Default) as
ArticleRequest;
var articleId = request?.ArticleId ?? request?.Id; var articleId = request?.ArticleId ?? request?.Id;
if (string.IsNullOrWhiteSpace(articleId)) if (string.IsNullOrWhiteSpace(articleId))
{ {
_logger.LogWarning("[{Channel}] [SentimentMqttClient] Missing articleId in request payload.", await finlyticLogger.LogWarningAsync(SettingKeys.SentimentChannel, "[SentimentMqttClient] Missing articleId in request payload.");
"SentimentChannel");
return; return;
} }
using var scope = _scopeFactory.CreateScope();
var storageService = scope.ServiceProvider.GetRequiredService<ISentimentStorageService>(); var storageService = scope.ServiceProvider.GetRequiredService<ISentimentStorageService>();
var sentimentEntry = await storageService.GetArticleSentimentAsync(articleId); var sentimentEntry = await storageService.GetArticleSentimentAsync(articleId);
string responseTopic = $"services/response/sentiment_GetArticle/{correlationId}"; string responseTopic = $"services/response/sentiment_GetArticle/{correlationId}";
_logger.LogInformation( await finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentMqttClient] Publishing RPC sentiment_GetArticle response for article {ArticleId} to {ResponseTopic}", articleId, responseTopic);
"[{Channel}] [SentimentMqttClient] Publishing RPC sentiment_GetArticle response for article {ArticleId} to {ResponseTopic}",
"SentimentChannel", articleId, responseTopic);
await PublishAsync(responseTopic, sentimentEntry); await PublishAsync(responseTopic, sentimentEntry);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, await finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[SentimentMqttClient] Failed to process sentiment_GetArticle RPC request.");
"[{Channel}] [SentimentMqttClient] Failed to process sentiment_GetArticle RPC request.",
"SentimentChannel");
} }
} }
/// <summary>
/// Handles sentiment_GetIsin RPC requests using source-generated DTO deserialization.
/// </summary>
private async Task OnSentimentGetIsinAsync(string payload, string correlationId) private async Task OnSentimentGetIsinAsync(string payload, string correlationId)
{ {
_logger.LogInformation(
"[{Channel}] [SentimentMqttClient] Processing RPC sentiment_GetIsin request [CorrelationId: {CorrelationId}]",
"SentimentChannel", correlationId);
if (string.IsNullOrWhiteSpace(payload)) return; if (string.IsNullOrWhiteSpace(payload)) return;
using var scope = _scopeFactory.CreateScope();
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<SentimentMqttClient>>();
await finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentMqttClient] Processing RPC sentiment_GetIsin request [CorrelationId: {CorrelationId}]", correlationId);
try try
{ {
var request = var request = JsonSerializer.Deserialize(payload, typeof(IsinRequest), FinlyticJsonSerializerContext.Default) as IsinRequest;
JsonSerializer.Deserialize(payload, typeof(IsinRequest), FinlyticJsonSerializerContext.Default) as
IsinRequest;
var isin = request?.Isin; var isin = request?.Isin;
if (string.IsNullOrWhiteSpace(isin)) if (string.IsNullOrWhiteSpace(isin))
{ {
_logger.LogWarning("[{Channel}] [SentimentMqttClient] Missing ISIN in request payload.", await finlyticLogger.LogWarningAsync(SettingKeys.SentimentChannel, "[SentimentMqttClient] Missing ISIN in request payload.");
"SentimentChannel");
return; return;
} }
using var scope = _scopeFactory.CreateScope();
var storageService = scope.ServiceProvider.GetRequiredService<ISentimentStorageService>(); var storageService = scope.ServiceProvider.GetRequiredService<ISentimentStorageService>();
var isinSummary = await storageService.GetIsinSummaryAsync(isin); var isinSummary = await storageService.GetIsinSummaryAsync(isin);
string responseTopic = $"services/response/sentiment_GetIsin/{correlationId}"; string responseTopic = $"services/response/sentiment_GetIsin/{correlationId}";
_logger.LogInformation( await finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentMqttClient] Publishing RPC sentiment_GetIsin response for ISIN {Isin} to {ResponseTopic}", isin, responseTopic);
"[{Channel}] [SentimentMqttClient] Publishing RPC sentiment_GetIsin response for ISIN {Isin} to {ResponseTopic}",
"SentimentChannel", isin, responseTopic);
await PublishAsync(responseTopic, isinSummary); await PublishAsync(responseTopic, isinSummary);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "[{Channel}] [SentimentMqttClient] Failed to process sentiment_GetIsin RPC request.", await finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[SentimentMqttClient] Failed to process sentiment_GetIsin RPC request.");
"SentimentChannel");
} }
} }
/// <summary>
/// Handles manual/forced sentiment_Analyze RPC requests using existing analyzer and storage services.
/// </summary>
private async Task OnSentimentAnalyzeAsync(string payload, string correlationId) private async Task OnSentimentAnalyzeAsync(string payload, string correlationId)
{ {
_logger.LogInformation( using var scope = _scopeFactory.CreateScope();
"[{Channel}] [SentimentMqttClient] Processing RPC sentiment_Analyze request [CorrelationId: {CorrelationId}]", var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<SentimentMqttClient>>();
"SentimentChannel", correlationId);
await finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentMqttClient] Processing RPC sentiment_Analyze request [CorrelationId: {CorrelationId}]", correlationId);
string responseTopic = $"services/response/sentiment_Analyze/{correlationId}"; string responseTopic = $"services/response/sentiment_Analyze/{correlationId}";
if (string.IsNullOrWhiteSpace(payload)) if (string.IsNullOrWhiteSpace(payload))
@@ -304,33 +337,24 @@ public class SentimentMqttClient : ManagedMqttClient, IHostedService
try try
{ {
var request = var request = JsonSerializer.Deserialize(payload, typeof(AnalyzeSentimentRequest), FinlyticJsonSerializerContext.Default) as AnalyzeSentimentRequest;
JsonSerializer.Deserialize(payload, typeof(AnalyzeSentimentRequest),
FinlyticJsonSerializerContext.Default) as AnalyzeSentimentRequest;
if (request == null || if (request == null || (string.IsNullOrWhiteSpace(request.ArticleId) && string.IsNullOrWhiteSpace(request.Isin)))
(string.IsNullOrWhiteSpace(request.ArticleId) && string.IsNullOrWhiteSpace(request.Isin)))
{ {
_logger.LogWarning( await finlyticLogger.LogWarningAsync(SettingKeys.SentimentChannel, "[SentimentMqttClient] Missing ArticleId or Isin in sentiment_Analyze request payload.");
"[{Channel}] [SentimentMqttClient] Missing ArticleId or Isin in sentiment_Analyze request payload.",
"SentimentChannel");
await PublishAsync(responseTopic, (object?)null); await PublishAsync(responseTopic, (object?)null);
return; return;
} }
using var scope = _scopeFactory.CreateScope();
var storageService = scope.ServiceProvider.GetRequiredService<ISentimentStorageService>(); var storageService = scope.ServiceProvider.GetRequiredService<ISentimentStorageService>();
var analyzerService = scope.ServiceProvider.GetRequiredService<IFinBertAnalyzerService>(); var analyzerService = scope.ServiceProvider.GetRequiredService<IFinBertAnalyzerService>();
object? result = null; object? result = null;
// Fall 1: Manuelle Analyse für einen einzelnen Artikel
if (!string.IsNullOrWhiteSpace(request.ArticleId)) if (!string.IsNullOrWhiteSpace(request.ArticleId))
{ {
var cleanArticleId = request.ArticleId.Trim(); var cleanArticleId = request.ArticleId.Trim();
_logger.LogInformation( await finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentMqttClient] Processing article analysis for ArticleId: {ArticleId} (ForceReload: {ForceReload})", cleanArticleId, request.ForceReload);
"[{Channel}] [SentimentMqttClient] Processing article analysis for ArticleId: {ArticleId} (ForceReload: {ForceReload})",
"SentimentChannel", cleanArticleId, request.ForceReload);
IsinAnalysisEntry? existingEntry = null; IsinAnalysisEntry? existingEntry = null;
@@ -345,7 +369,6 @@ public class SentimentMqttClient : ManagedMqttClient, IHostedService
} }
else else
{ {
// Artikel per RPC von FinlyticNews abfragen
if (Guid.TryParse(cleanArticleId, out var articleGuid)) if (Guid.TryParse(cleanArticleId, out var articleGuid))
{ {
var article = await SendRpcRequestAsync<NewsArticleDto, ArticleRequest>( var article = await SendRpcRequestAsync<NewsArticleDto, ArticleRequest>(
@@ -357,17 +380,13 @@ public class SentimentMqttClient : ManagedMqttClient, IHostedService
{ {
var finbertResult = await analyzerService.AnalyzeArticleAsync(article); var finbertResult = await analyzerService.AnalyzeArticleAsync(article);
// 🎯 NULL-CHECK: Falls Analyse fehlschlägt/null liefert -> abbrechen
if (finbertResult == null) if (finbertResult == null)
{ {
_logger.LogWarning( await finlyticLogger.LogWarningAsync(SettingKeys.SentimentChannel, "[SentimentMqttClient] FinBERT analysis returned NULL for article {ArticleId}. Aborting manual analysis.", cleanArticleId);
"[{Channel}] [SentimentMqttClient] FinBERT analysis returned NULL for article {ArticleId}. Aborting manual analysis.",
"SentimentChannel", cleanArticleId);
await PublishAsync(responseTopic, (object?)null); await PublishAsync(responseTopic, (object?)null);
return; return;
} }
// Speichern & Summaries aktualisieren
await storageService.SaveArticleSentimentAsync(article, finbertResult); await storageService.SaveArticleSentimentAsync(article, finbertResult);
if (article.MatchedAssets != null && article.MatchedAssets.Count > 0) if (article.MatchedAssets != null && article.MatchedAssets.Count > 0)
@@ -391,86 +410,62 @@ public class SentimentMqttClient : ManagedMqttClient, IHostedService
} }
} }
// FinlyticNews über Re-Analyse informieren
await UpdateArticleStatusAsync(article.Id, "Analyzed"); await UpdateArticleStatusAsync(article.Id, "Analyzed");
result = await storageService.GetArticleSentimentAsync(cleanArticleId); result = await storageService.GetArticleSentimentAsync(cleanArticleId);
} }
else else
{ {
_logger.LogWarning( await finlyticLogger.LogWarningAsync(SettingKeys.SentimentChannel, "[SentimentMqttClient] Could not retrieve article {ArticleId} from FinlyticNews for re-analysis.", cleanArticleId);
"[{Channel}] [SentimentMqttClient] Could not retrieve article {ArticleId} from FinlyticNews for re-analysis.",
"SentimentChannel", cleanArticleId);
} }
} }
} }
} }
// Fall 2: ISIN Gesamtsummary anfordern
else if (!string.IsNullOrWhiteSpace(request.Isin)) else if (!string.IsNullOrWhiteSpace(request.Isin))
{ {
var cleanIsin = request.Isin.Trim(); var cleanIsin = request.Isin.Trim();
_logger.LogInformation("[{Channel}] [SentimentMqttClient] Fetching sentiment summary for ISIN: {Isin}", await finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentMqttClient] Fetching sentiment summary for ISIN: {Isin}", cleanIsin);
"SentimentChannel", cleanIsin);
result = await storageService.GetIsinSummaryAsync(cleanIsin); result = await storageService.GetIsinSummaryAsync(cleanIsin);
} }
_logger.LogInformation( await finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentMqttClient] Publishing RPC sentiment_Analyze response to {ResponseTopic}", responseTopic);
"[{Channel}] [SentimentMqttClient] Publishing RPC sentiment_Analyze response to {ResponseTopic}",
"SentimentChannel", responseTopic);
await PublishAsync(responseTopic, result); await PublishAsync(responseTopic, result);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "[{Channel}] [SentimentMqttClient] Failed to process sentiment_Analyze RPC request.", await finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[SentimentMqttClient] Failed to process sentiment_Analyze RPC request.");
"SentimentChannel");
await PublishAsync(responseTopic, (object?)null); await PublishAsync(responseTopic, (object?)null);
} }
} }
/// <summary>
/// Requests pending news articles from FinlyticNews via MQTT RPC.
/// </summary>
/// <param name="limit">The maximum number of articles to request (capped at 10).</param>
/// <returns>A list of pending news article DTOs.</returns>
public async Task<List<NewsArticleDto>> GetPendingArticlesAsync(int limit = 10) public async Task<List<NewsArticleDto>> GetPendingArticlesAsync(int limit = 10)
{ {
try try
{ {
var payload = new LimitRequest(Math.Min(limit, 10)); var payload = new LimitRequest(Math.Min(limit, 10));
var articles = var articles = await SendRpcRequestAsync<List<NewsArticleDto>, LimitRequest>("news_GetPending", payload, TimeSpan.FromSeconds(10));
await SendRpcRequestAsync<List<NewsArticleDto>, LimitRequest>("news_GetPending", payload,
TimeSpan.FromSeconds(10));
return articles ?? []; return articles ?? [];
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "[{Channel}] Error executing MQTT RPC for news_GetPending.", "SentimentChannel"); _logger.LogError(ex, "Error executing MQTT RPC for news_GetPending.");
} }
return []; return [];
} }
/// <summary>
/// Dispatches an RPC request to update the article status in FinlyticNews (e.g. to "Analyzed").
/// </summary>
/// <param name="id">The article identifier.</param>
/// <param name="status">The target status string (default "Analyzed").</param>
/// <returns>True if the status update succeeded; otherwise, false.</returns>
public async Task<bool> UpdateArticleStatusAsync(Guid id, string status = "Analyzed") public async Task<bool> UpdateArticleStatusAsync(Guid id, string status = "Analyzed")
{ {
try try
{ {
var request = new UpdateNewsStatusRequest(id, status); var request = new UpdateNewsStatusRequest(id, status);
var response = var response = await SendRpcRequestAsync<UpdateNewsStatusResponse, UpdateNewsStatusRequest>("news_UpdateStatus", request, TimeSpan.FromSeconds(8));
await SendRpcRequestAsync<UpdateNewsStatusResponse, UpdateNewsStatusRequest>("news_UpdateStatus",
request, TimeSpan.FromSeconds(8));
return response?.Success ?? false; return response?.Success ?? false;
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "[{Channel}] Error executing MQTT RPC for news_UpdateStatus (ID: {Id}).", _logger.LogError(ex, "Error executing MQTT RPC for news_UpdateStatus (ID: {Id}).", id);
"SentimentChannel", id);
} }
return false; return false;
+19
View File
@@ -0,0 +1,19 @@
using FinlyticCore.Models.Settings;
namespace FinlyticSentiment.Util;
public static class SettingKeys
{
// --- Logging-Kanäle ---
public static readonly SettingKey<bool> SentimentChannel = new("Logging.Channel.Sentiment", true);
public static readonly SettingKey<bool> MqttChannel = new("Logging.Channel.MQTT", true);
public static readonly SettingKey<bool> HealthPingChannel = new("Logging.Channel.Health", true);
// --- FinBERT & Modell-Parameter ---
public static readonly SettingKey<int> MaxBatchSize = new("FinBert.MaxBatchSize", 10);
public static readonly SettingKey<double> MinimumConfidenceThreshold = new("FinBert.MinConfidenceThreshold", 0.60);
public static readonly SettingKey<int> TimeoutSeconds = new("FinBert.TimeoutSeconds", 30);
public static readonly SettingKey<int> SentimentWindowDays = new("Sentiment.WindowDays", 14);
public static readonly SettingKey<double> DecayFactorPerDay = new("Sentiment.DecayFactorPerDay", 0.90);
public static readonly SettingKey<bool> EnableAutoSummarization = new("Feature.EnableAutoSummarization", true);
}