From 05d55a324caff43b23baae3ff8c2228c9af0c639 Mon Sep 17 00:00:00 2001 From: Kleidukos Date: Sun, 9 Aug 2026 21:01:42 +0200 Subject: [PATCH] feat(Sentiment): update sentiment service --- .../Database/SentimentDbContext.cs | 29 ++ FinlyticSentiment/Dockerfile | 22 + .../Entities/SentimentSettingsEntity.cs | 23 + FinlyticSentiment/FinlyticSentiment.csproj | 26 + ...90401_InitialSentimentSettings.Designer.cs | 63 +++ ...20260801090401_InitialSentimentSettings.cs | 39 ++ .../SentimentDbContextModelSnapshot.cs | 60 +++ FinlyticSentiment/Program.cs | 44 ++ FinlyticSentiment/Project.md | 34 ++ .../Services/FinBertAnalyzerService.cs | 204 ++++++++ .../Services/SentimentBackgroundService.cs | 216 ++++++++ .../Services/SentimentStorageService.cs | 395 +++++++++++++++ .../Services/SettingsDbService.cs | 115 +++++ FinlyticSentiment/Util/SentimentMqttClient.cs | 478 ++++++++++++++++++ FinlyticSentiment/appsettings.json | 24 + 15 files changed, 1772 insertions(+) create mode 100644 FinlyticSentiment/Database/SentimentDbContext.cs create mode 100644 FinlyticSentiment/Dockerfile create mode 100644 FinlyticSentiment/Entities/SentimentSettingsEntity.cs create mode 100644 FinlyticSentiment/FinlyticSentiment.csproj create mode 100644 FinlyticSentiment/Migrations/20260801090401_InitialSentimentSettings.Designer.cs create mode 100644 FinlyticSentiment/Migrations/20260801090401_InitialSentimentSettings.cs create mode 100644 FinlyticSentiment/Migrations/SentimentDbContextModelSnapshot.cs create mode 100644 FinlyticSentiment/Program.cs create mode 100644 FinlyticSentiment/Project.md create mode 100644 FinlyticSentiment/Services/FinBertAnalyzerService.cs create mode 100644 FinlyticSentiment/Services/SentimentBackgroundService.cs create mode 100644 FinlyticSentiment/Services/SentimentStorageService.cs create mode 100644 FinlyticSentiment/Services/SettingsDbService.cs create mode 100644 FinlyticSentiment/Util/SentimentMqttClient.cs create mode 100644 FinlyticSentiment/appsettings.json diff --git a/FinlyticSentiment/Database/SentimentDbContext.cs b/FinlyticSentiment/Database/SentimentDbContext.cs new file mode 100644 index 0000000..85b0240 --- /dev/null +++ b/FinlyticSentiment/Database/SentimentDbContext.cs @@ -0,0 +1,29 @@ +using FinlyticSentiment.Entities; +using Microsoft.EntityFrameworkCore; + +namespace FinlyticSentiment.Database; + +/// +/// EF Core DbContext for managing FinlyticSentiment settings in PostgreSQL. +/// +public class SentimentDbContext : DbContext +{ + public SentimentDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet Settings => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(entity => + { + entity.ToTable("sentiment_settings"); + entity.HasKey(e => e.Id); + entity.Property(e => e.GermanWebhookUrl).HasMaxLength(500); + entity.Property(e => e.EnglishWebhookUrl).HasMaxLength(500); + }); + } +} diff --git a/FinlyticSentiment/Dockerfile b/FinlyticSentiment/Dockerfile new file mode 100644 index 0000000..4a00792 --- /dev/null +++ b/FinlyticSentiment/Dockerfile @@ -0,0 +1,22 @@ +FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base +USER app +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["FinlyticSentiment/FinlyticSentiment.csproj", "FinlyticSentiment/"] +COPY ["FinlyticCore/FinlyticCore.csproj", "FinlyticCore/"] +RUN dotnet restore "FinlyticSentiment/FinlyticSentiment.csproj" +COPY . . +WORKDIR "/src/FinlyticSentiment" +RUN dotnet build "FinlyticSentiment.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "FinlyticSentiment.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "FinlyticSentiment.dll"] diff --git a/FinlyticSentiment/Entities/SentimentSettingsEntity.cs b/FinlyticSentiment/Entities/SentimentSettingsEntity.cs new file mode 100644 index 0000000..6377eb0 --- /dev/null +++ b/FinlyticSentiment/Entities/SentimentSettingsEntity.cs @@ -0,0 +1,23 @@ +using System; + +namespace FinlyticSentiment.Entities; + +/// +/// Entity representing runtime operational settings for FinlyticSentiment stored in PostgreSQL. +/// +public class SentimentSettingsEntity +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public double MinConfidenceScore { get; set; } = 0.70; + + public int MaxBatchSize { get; set; } = 10; + + public int SweepIntervalMinutes { get; set; } = 2; + + public string GermanWebhookUrl { get; set; } = "https://n8n.kleidukos.me/webhook/sentiment/de"; + + public string EnglishWebhookUrl { get; set; } = "https://n8n.kleidukos.me/webhook/sentiment/en"; + + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/FinlyticSentiment/FinlyticSentiment.csproj b/FinlyticSentiment/FinlyticSentiment.csproj new file mode 100644 index 0000000..7a02b00 --- /dev/null +++ b/FinlyticSentiment/FinlyticSentiment.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + Linux + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/FinlyticSentiment/Migrations/20260801090401_InitialSentimentSettings.Designer.cs b/FinlyticSentiment/Migrations/20260801090401_InitialSentimentSettings.Designer.cs new file mode 100644 index 0000000..febe352 --- /dev/null +++ b/FinlyticSentiment/Migrations/20260801090401_InitialSentimentSettings.Designer.cs @@ -0,0 +1,63 @@ +// +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("20260801090401_InitialSentimentSettings")] + partial class InitialSentimentSettings + { + /// + 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("FinlyticSentiment.Entities.SentimentSettingsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EnglishWebhookUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("GermanWebhookUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("MaxBatchSize") + .HasColumnType("integer"); + + b.Property("MinConfidenceScore") + .HasColumnType("double precision"); + + b.Property("SweepIntervalMinutes") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("sentiment_settings", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FinlyticSentiment/Migrations/20260801090401_InitialSentimentSettings.cs b/FinlyticSentiment/Migrations/20260801090401_InitialSentimentSettings.cs new file mode 100644 index 0000000..3663991 --- /dev/null +++ b/FinlyticSentiment/Migrations/20260801090401_InitialSentimentSettings.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FinlyticSentiment.Migrations +{ + /// + public partial class InitialSentimentSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "sentiment_settings", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + MinConfidenceScore = table.Column(type: "double precision", nullable: false), + MaxBatchSize = table.Column(type: "integer", nullable: false), + SweepIntervalMinutes = table.Column(type: "integer", nullable: false), + GermanWebhookUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + EnglishWebhookUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_sentiment_settings", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "sentiment_settings"); + } + } +} diff --git a/FinlyticSentiment/Migrations/SentimentDbContextModelSnapshot.cs b/FinlyticSentiment/Migrations/SentimentDbContextModelSnapshot.cs new file mode 100644 index 0000000..5d0acd5 --- /dev/null +++ b/FinlyticSentiment/Migrations/SentimentDbContextModelSnapshot.cs @@ -0,0 +1,60 @@ +// +using System; +using FinlyticSentiment.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FinlyticSentiment.Migrations +{ + [DbContext(typeof(SentimentDbContext))] + partial class SentimentDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FinlyticSentiment.Entities.SentimentSettingsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EnglishWebhookUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("GermanWebhookUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("MaxBatchSize") + .HasColumnType("integer"); + + b.Property("MinConfidenceScore") + .HasColumnType("double precision"); + + b.Property("SweepIntervalMinutes") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("sentiment_settings", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FinlyticSentiment/Program.cs b/FinlyticSentiment/Program.cs new file mode 100644 index 0000000..64c1a54 --- /dev/null +++ b/FinlyticSentiment/Program.cs @@ -0,0 +1,44 @@ +using FinlyticSentiment.Database; +using FinlyticSentiment.Services; +using FinlyticSentiment.Util; +using Microsoft.EntityFrameworkCore; + +var builder = Host.CreateApplicationBuilder(args); + +// Register PostgreSQL DbContext for settings persistence +builder.Services.AddDbContext(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); + +// Register HttpClient +builder.Services.AddHttpClient(); + +// Register Service interfaces and co-located implementations +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// Register MQTT Client (as singleton hosted service) +builder.Services.AddSingleton(); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); + +// Register Sentiment Background Worker +builder.Services.AddHostedService(); + +var host = builder.Build(); + +// Auto-migrate database on startup +using (var scope = host.Services.CreateScope()) +{ + try + { + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); + } + catch (Exception ex) + { + var logger = scope.ServiceProvider.GetRequiredService>(); + logger.LogError(ex, "An error occurred during database migration for FinlyticSentiment on startup."); + } +} + +await host.RunAsync(); diff --git a/FinlyticSentiment/Project.md b/FinlyticSentiment/Project.md new file mode 100644 index 0000000..0458002 --- /dev/null +++ b/FinlyticSentiment/Project.md @@ -0,0 +1,34 @@ +# Finlytic Sentiment Service + +Finlytic Sentiment is a C# microservice dedicated to real-time AI sentiment analysis of financial news. It consumes pending news articles, runs FinBERT neural model evaluations, and aggregates sentiment scores at the asset (ISIN) and sector level. + +--- + +## Core Features & Architecture + +1. **FinBERT AI Integration**: + - Evaluates positive, negative, and neutral sentiment probabilities (`positiveProbability`, `negativeProbability`, `neutralProbability`, `compoundScore`). + +2. **ISIN & Sector Sentiment Aggregation**: + - Aggregates sentiment scores per financial asset (`IsinSentimentSummaryDto`) and sector (`SectorSentimentSummaryDto`). + +3. **Background Worker Engine**: + - Runs a 5-minute background loop (`SentimentBackgroundService`) polling pending articles, processing FinBERT evaluations, and publishing results over MQTT. + +4. **MQTT Event Channels**: + - Publishes updates to `finlytic/sentiment/result` and responds to RPC requests on `services/request/sentiment_GetArticle/#` and `services/request/sentiment_GetIsin/#`. + +--- + +## Feature Status + +### Implemented Features +- [x] FinBERT AI Sentiment Evaluation Service (`IFinBertAnalyzerService`). +- [x] Sentiment Storage & In-Memory Aggregation (`ISentimentStorageService`). +- [x] 5-minute Background Worker loop (`SentimentBackgroundService`). +- [x] Zero-Allocation MQTT serialization via `FinlyticJsonSerializerContext`. +- [x] Pure Worker Service architecture (`Host.CreateApplicationBuilder`, no Kestrel HTTP server). + +### Planned Features +- [ ] Historical sentiment trend charting (multi-month sentiment drift per asset). +- [ ] Financial entity sentiment impact correlation model. diff --git a/FinlyticSentiment/Services/FinBertAnalyzerService.cs b/FinlyticSentiment/Services/FinBertAnalyzerService.cs new file mode 100644 index 0000000..5504ede --- /dev/null +++ b/FinlyticSentiment/Services/FinBertAnalyzerService.cs @@ -0,0 +1,204 @@ +using System.Net.Http.Json; +using System.Text.Json; +using FinlyticCore.Dtos.News; +using FinlyticCore.Dtos.Sentiment; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace FinlyticSentiment.Services; + +/// +/// Defines the sentiment analysis contract for evaluating news articles via FinBERT webhooks. +/// +public interface IFinBertAnalyzerService +{ + /// + /// Analyzes a news article using language-targeted FinBERT webhooks and returns structured metrics. + /// + /// The news article DTO to evaluate. + /// A task returning the FinBERT sentiment analysis result. + Task AnalyzeArticleAsync(NewsArticleDto article); +} + +/// +/// Implementation of the sentiment analysis contract for evaluating news articles via FinBERT webhooks. +/// +public class FinBertAnalyzerService : IFinBertAnalyzerService +{ + private readonly HttpClient _httpClient; + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP client instance. + /// The service scope factory for DB access. + /// The logging channel. + public FinBertAnalyzerService( + HttpClient httpClient, + IServiceScopeFactory scopeFactory, + ILogger logger) + { + _httpClient = httpClient; + _scopeFactory = scopeFactory; + _logger = logger; + } + + /// + /// Analyzes a news article using language-targeted FinBERT webhooks and returns structured metrics. + /// + public async Task AnalyzeArticleAsync(NewsArticleDto article) + { + ArgumentNullException.ThrowIfNull(article); + + string targetUrl; + double minConfidence; + using (var scope = _scopeFactory.CreateScope()) + { + var settingsDb = scope.ServiceProvider.GetRequiredService(); + var settings = await settingsDb.GetSettingsAsync(); + targetUrl = string.Equals(article.Language, "en", StringComparison.OrdinalIgnoreCase) + ? settings.EnglishWebhookUrl + : settings.GermanWebhookUrl; + 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); + + var requestBody = new + { + article_id = article.Id.ToString(), + title = article.Title, + summary = article.Summary ?? string.Empty, + content = article.ContentRaw ?? string.Empty, + source = article.Author ?? "FinlyticNews", + source_url = article.SourceUrl, + published_at = article.PublishedAt.ToString("o") + }; + + try + { + using var response = await _httpClient.PostAsJsonAsync(targetUrl, requestBody); + if (response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(); + if (!string.IsNullOrWhiteSpace(content)) + { + using var doc = JsonDocument.Parse(content); + 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) + { + root = root[0]; + } + + // Unwrap n8n wrapper objects: "json", "output", "data", "result", "body" + if (root.ValueKind == JsonValueKind.Object) + { + if (root.TryGetProperty("json", out var jsonChild) && jsonChild.ValueKind == JsonValueKind.Object) + root = jsonChild; + else if (root.TryGetProperty("output", out var outChild) && outChild.ValueKind == JsonValueKind.Object) + root = outChild; + else if (root.TryGetProperty("data", out var dataChild) && dataChild.ValueKind == JsonValueKind.Object) + root = dataChild; + else if (root.TryGetProperty("body", out var bodyChild) && bodyChild.ValueKind == JsonValueKind.Object) + root = bodyChild; + } + + if (root.ValueKind == JsonValueKind.Object) + { + string rawLabel = GetStringProp(root, "label") + ?? GetStringProp(root, "sentiment_label") + ?? GetStringProp(root, "sentiment") + ?? "NEUTRAL"; + double compoundScore = GetDoubleProp(root, "compound_score") + ?? GetDoubleProp(root, "compoundScore") + ?? GetDoubleProp(root, "score") + ?? 0.0; + double confidence = GetDoubleProp(root, "confidence") + ?? GetDoubleProp(root, "confidence_score") + ?? 0.5; + string snippet = GetStringProp(root, "summary_snippet") + ?? GetStringProp(root, "summary") + ?? GetStringProp(root, "text") + ?? article.Summary + ?? article.Title; + + double pos = 0.0, neg = 0.0, neu = 1.0; + if (root.TryGetProperty("probabilities", out var probsElem) && probsElem.ValueKind == JsonValueKind.Object) + { + pos = GetDoubleProp(probsElem, "positive") ?? pos; + neg = GetDoubleProp(probsElem, "negative") ?? neg; + neu = GetDoubleProp(probsElem, "neutral") ?? neu; + } + + // Normalize German vs English labels + string label = rawLabel.Trim().ToUpperInvariant() switch + { + "POSITIV" or "POSITIVE" => "POSITIVE", + "NEGATIV" or "NEGATIVE" => "NEGATIVE", + _ => "NEUTRAL" + }; + + // If compoundScore is 0 but probabilities or label indicate sentiment, compute compoundScore + if (Math.Abs(compoundScore) < 0.001) + { + if (pos > 0 || neg > 0) + { + compoundScore = pos - neg; + } + else if (label == "POSITIVE") + { + compoundScore = 0.8; + } + else if (label == "NEGATIVE") + { + compoundScore = -0.8; + } + } + + return new FinBertResultDto + { + Label = label, + CompoundScore = Math.Round(compoundScore, 4), + Confidence = Math.Round(confidence, 4), + Probabilities = new FinBertProbabilities + { + Positive = Math.Round(pos, 4), + Negative = Math.Round(neg, 4), + Neutral = Math.Round(neu, 4) + }, + SummarySnippet = snippet + }; + } + + } + } + + _logger.LogWarning("[{Channel}] n8n Webhook returned non-success status: {StatusCode}. Falling back to rule analyzer.", "SentimentChannel", response.StatusCode); + } + catch (Exception ex) + { + _logger.LogError(ex, "[{Channel}] Failed to call n8n sentiment webhook. Executing fallback sentiment analyzer.", "SentimentChannel"); + } + + return null; + } + + private static string? GetStringProp(JsonElement elem, string propName) + { + return elem.TryGetProperty(propName, out var prop) && prop.ValueKind == JsonValueKind.String ? prop.GetString() : null; + } + + private static double? GetDoubleProp(JsonElement elem, string propName) + { + if (elem.TryGetProperty(propName, out var prop)) + { + if (prop.ValueKind == JsonValueKind.Number && prop.TryGetDouble(out var d)) return d; + if (prop.ValueKind == JsonValueKind.String && double.TryParse(prop.GetString(), out var parsed)) return parsed; + } + return null; + } +} diff --git a/FinlyticSentiment/Services/SentimentBackgroundService.cs b/FinlyticSentiment/Services/SentimentBackgroundService.cs new file mode 100644 index 0000000..87a5286 --- /dev/null +++ b/FinlyticSentiment/Services/SentimentBackgroundService.cs @@ -0,0 +1,216 @@ +using System.Collections.Concurrent; +using FinlyticCore.Dtos.News; +using FinlyticSentiment.Util; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace FinlyticSentiment.Services; + +/// +/// Background hosted worker executing periodic sentiment analysis sweeps on pending news articles +/// and processing real-time article broadcasts. +/// +public class SentimentBackgroundService : BackgroundService +{ + private readonly SentimentMqttClient _mqttClient; + private readonly IFinBertAnalyzerService _analyzer; + private readonly ISentimentStorageService _storage; + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + // In-Memory Mutex / Cache zur Vermeidung doppelter Verarbeitung (Race Conditions zwischen Broadcast & Sweep) + private static readonly ConcurrentDictionary ProcessingArticles = new(); + + /// + /// Initializes a new instance of the class. + /// + public SentimentBackgroundService( + SentimentMqttClient mqttClient, + IFinBertAnalyzerService analyzer, + ISentimentStorageService storage, + IServiceScopeFactory scopeFactory, + ILogger logger) + { + _mqttClient = mqttClient; + _analyzer = analyzer; + _storage = storage; + _scopeFactory = scopeFactory; + _logger = logger; + } + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("[{Channel}] FinlyticSentiment Background Service started.", "SentimentChannel"); + + // 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; + + using (var scope = _scopeFactory.CreateScope()) + { + var settingsDb = scope.ServiceProvider.GetRequiredService(); + var settings = await settingsDb.GetSettingsAsync(); + maxBatchSize = settings.MaxBatchSize; + sweepIntervalMinutes = settings.SweepIntervalMinutes; + } + + var interval = TimeSpan.FromMinutes(Math.Max(1, sweepIntervalMinutes)); + + try + { + await PerformSentimentSweepAsync(maxBatchSize, stoppingToken); + } + 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"); + } + + _logger.LogInformation("[{Channel}] Waiting {Minutes} minute(s) until next sentiment sweep...", + "SentimentChannel", interval.TotalMinutes); + + // Verwendet PeriodicTimer oder CancellationToken-resistenten Delay + using var timer = new PeriodicTimer(interval); + try + { + await timer.WaitForNextTickAsync(stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + } + + _logger.LogInformation("[{Channel}] FinlyticSentiment Background Service is shutting down gracefully.", + "SentimentChannel"); + } + + /// + /// Performs a single batch sweep of pending news articles. + /// + private async Task PerformSentimentSweepAsync(int maxBatchSize, CancellationToken cancellationToken) + { + _logger.LogInformation("[{Channel}] Starting sentiment sweep for pending news articles (Limit: {Limit})...", + "SentimentChannel", maxBatchSize); + + List pendingArticles = await _mqttClient.GetPendingArticlesAsync(limit: maxBatchSize); + if (pendingArticles.Count == 0) + { + _logger.LogInformation("[{Channel}] No pending news articles found in FinlyticNews.", "SentimentChannel"); + return; + } + + _logger.LogInformation("[{Channel}] Retrieved {Count} pending article(s) for FinBERT sentiment evaluation.", + "SentimentChannel", pendingArticles.Count); + + foreach (var article in pendingArticles) + { + if (cancellationToken.IsCancellationRequested) break; + await ProcessSingleArticleAsync(article, cancellationToken); + } + } + + /// + /// Processes FinBERT sentiment analysis for a single article, updates two-stage JSON summaries, and notifies FinlyticNews of status update. + /// + 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); + return; + } + + try + { + _logger.LogInformation("[{Channel}] Evaluating FinBERT sentiment for article: '{Title}' (ID: {Id})", + "SentimentChannel", 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); + 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) + { + if (cancellationToken.IsCancellationRequested) break; + if (string.IsNullOrWhiteSpace(asset.Isin)) continue; + + await _storage.UpdateIsinSummaryAsync( + asset.Isin, + asset.Name, + "General", + article, + finbert); + + await _storage.UpdateSectorSummaryAsync( + "General", + asset.Isin, + article.Id.ToString(), + finbert); + } + } + + 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); + } + else + { + _logger.LogWarning( + "[{Channel}] Failed to confirm status update to 'Analyzed' in FinlyticNews for article: {Id}", + "SentimentChannel", article.Id); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "[{Channel}] Error processing sentiment for article: {Id} ({Title})", + "SentimentChannel", article.Id, article.Title); + } + finally + { + // Lock nach der Verarbeitung immer freigeben + ProcessingArticles.TryRemove(article.Id, out _); + } + } +} \ No newline at end of file diff --git a/FinlyticSentiment/Services/SentimentStorageService.cs b/FinlyticSentiment/Services/SentimentStorageService.cs new file mode 100644 index 0000000..33ca733 --- /dev/null +++ b/FinlyticSentiment/Services/SentimentStorageService.cs @@ -0,0 +1,395 @@ +using System.Collections.Concurrent; +using System.Text.Encodings.Web; +using System.Text.Json; +using FinlyticCore.Dtos.News; +using FinlyticCore.Dtos.Sentiment; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace FinlyticSentiment.Services; + +/// +/// Defines the persistence contract for maintaining two-stage ISIN and Sector JSON sentiment summaries in the file system. +/// +public interface ISentimentStorageService +{ + /// + /// Appends a new FinBERT analysis event to the ISIN summary file and recalculates the current aggregate metrics. + /// + /// The asset ISIN code. + /// The name of the asset company. + /// The sector associated with the asset. + /// The analyzed article DTO. + /// The FinBERT sentiment result. + /// A task representing the file update operation. + Task UpdateIsinSummaryAsync(string isin, string companyName, string sector, NewsArticleDto article, FinBertResultDto finbert); + + /// + /// Appends a new FinBERT analysis event to the Sector summary file and recalculates the sector aggregate metrics. + /// + /// The target sector name. + /// The asset ISIN code triggering the sector update. + /// The unique news article identifier. + /// The FinBERT sentiment result. + /// A task representing the file update operation. + Task UpdateSectorSummaryAsync(string sector, string isin, string articleId, FinBertResultDto finbert); + + /// + /// Persists an article's sentiment analysis directly in data/summaries/articles/{articleId}.json. + /// + Task SaveArticleSentimentAsync(NewsArticleDto article, FinBertResultDto finbert); + + /// + /// Retrieves the sentiment analysis entry for a specific news article. + /// + Task GetArticleSentimentAsync(string articleId); + + /// + /// Retrieves the aggregate sentiment summary for a specific asset ISIN. + /// + Task GetIsinSummaryAsync(string isin); +} + +public class SentimentStorageService : ISentimentStorageService +{ + private static readonly ConcurrentDictionary FileLocks = new(); + + private readonly ILogger _logger; + private readonly string _basePath; + private readonly JsonSerializerOptions _jsonOptions; + + public SentimentStorageService(IConfiguration configuration, ILogger logger) + { + _logger = logger; + _basePath = configuration["Storage:SummariesPath"] ?? "data/summaries"; + + Directory.CreateDirectory(Path.Combine(_basePath, "isin")); + Directory.CreateDirectory(Path.Combine(_basePath, "sectors")); + Directory.CreateDirectory(Path.Combine(_basePath, "articles")); + + _jsonOptions = new JsonSerializerOptions + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + } + + /// + public async Task SaveArticleSentimentAsync(NewsArticleDto article, FinBertResultDto finbert) + { + if (article == null || article.Id == Guid.Empty) return; + + string cleanId = article.Id.ToString(); + string filePath = Path.Combine(_basePath, "articles", $"{cleanId}.json"); + + var fileLock = FileLocks.GetOrAdd(filePath, _ => new SemaphoreSlim(1, 1)); + await fileLock.WaitAsync(); + + try + { + 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 + { + AnalysisId = analysisId, + Timestamp = nowIso, + Article = new IsinAnalysisArticleRef + { + ArticleId = cleanId, + Title = article.Title ?? "", + Source = article.Author ?? "FinlyticNews", + PublishedAt = article.PublishedAt.ToString("yyyy-MM-ddTHH:mm:ssZ") + }, + FinbertResult = finbert, + SummarySnippet = finbert.SummarySnippet ?? article.Summary ?? article.Title ?? "" + }; + + string json = JsonSerializer.Serialize(entry, _jsonOptions); + await File.WriteAllTextAsync(filePath, json); + _logger.LogInformation("[{Channel}] Successfully saved article sentiment file: {Path}", "SentimentChannel", filePath); + } + catch (Exception ex) + { + _logger.LogError(ex, "[{Channel}] Failed to write article sentiment file: {Path}", "SentimentChannel", filePath); + } + finally + { + fileLock.Release(); + } + } + + /// + public async Task GetArticleSentimentAsync(string articleId) + { + if (string.IsNullOrWhiteSpace(articleId)) return null; + + var cleanId = articleId.Trim(); + var articleFilePath = Path.Combine(_basePath, "articles", $"{cleanId}.json"); + + // 1. Primärer Lookup + if (File.Exists(articleFilePath)) + { + var fileLock = FileLocks.GetOrAdd(articleFilePath, _ => new SemaphoreSlim(1, 1)); + await fileLock.WaitAsync(); + try + { + var json = await File.ReadAllTextAsync(articleFilePath); + return JsonSerializer.Deserialize(json, _jsonOptions); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[{Channel}] Failed to read article sentiment file: {Path}", "SentimentChannel", articleFilePath); + } + finally + { + fileLock.Release(); + } + } + + // 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(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; + } + + /// + public async Task GetIsinSummaryAsync(string isin) + { + if (string.IsNullOrWhiteSpace(isin)) return null; + + var cleanIsin = isin.Trim(); + var filePath = Path.Combine(_basePath, "isin", $"{cleanIsin}.json"); + + if (!File.Exists(filePath)) return null; + + var fileLock = FileLocks.GetOrAdd(filePath, _ => new SemaphoreSlim(1, 1)); + await fileLock.WaitAsync(); + + try + { + var json = await File.ReadAllTextAsync(filePath); + return JsonSerializer.Deserialize(json, _jsonOptions); + } + catch (Exception ex) + { + _logger.LogError(ex, "[{Channel}] Error reading ISIN summary file: {Path}", "SentimentChannel", filePath); + return null; + } + finally + { + fileLock.Release(); + } + } + + /// + public async Task UpdateIsinSummaryAsync(string isin, string companyName, string sector, NewsArticleDto article, FinBertResultDto finbert) + { + if (string.IsNullOrWhiteSpace(isin) || article == null) return; + + string cleanIsin = isin.Trim(); + string filePath = Path.Combine(_basePath, "isin", $"{cleanIsin}.json"); + + var fileLock = FileLocks.GetOrAdd(filePath, _ => new SemaphoreSlim(1, 1)); + await fileLock.WaitAsync(); + + try + { + IsinSentimentSummaryDto isinDoc; + + if (File.Exists(filePath)) + { + try + { + string json = await File.ReadAllTextAsync(filePath); + isinDoc = JsonSerializer.Deserialize(json, _jsonOptions) ?? new IsinSentimentSummaryDto { Isin = cleanIsin }; + } + catch + { + isinDoc = new IsinSentimentSummaryDto { Isin = cleanIsin }; + } + } + else + { + isinDoc = new IsinSentimentSummaryDto { Isin = cleanIsin }; + } + + 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 + { + AnalysisId = analysisId, + Timestamp = nowIso, + Article = new IsinAnalysisArticleRef + { + ArticleId = article.Id.ToString(), + Title = article.Title ?? "", + Source = article.Author ?? "FinlyticNews", + PublishedAt = article.PublishedAt.ToString("yyyy-MM-ddTHH:mm:ssZ") + }, + FinbertResult = finbert, + SummarySnippet = finbert.SummarySnippet ?? article.Summary ?? article.Title ?? "" + }; + + // Duplikat-Bereinigung: Falls Artikel bereits existiert, alten Eintrag entfernen! + var updatedAnalyses = isinDoc.Analyses? + .Where(a => !string.Equals(a.Article?.ArticleId, article.Id.ToString(), StringComparison.OrdinalIgnoreCase)) + .ToList() ?? new List(); + + // Neuen Eintrag oben einfügen + updatedAnalyses.Insert(0, newEntry); + + // Capping: Maximal die letzten 100 Analysen aufheben (verhindert gigantische JSON-Dateien) + if (updatedAnalyses.Count > 100) + { + updatedAnalyses = updatedAnalyses.Take(100).ToList(); + } + + double avgCompound = updatedAnalyses.Average(a => a.FinbertResult.CompoundScore); + double avgConf = updatedAnalyses.Average(a => a.FinbertResult.Confidence); + string label = CalculateLabel(avgCompound); + + string textSummary = label switch + { + "POSITIVE" => $"Die Stimmungsanalyse zeigt einen weiterhin positiven Trend ({avgCompound:F2}). Hauptursache sind positive Berichte und starke Markt-Signale.", + "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 + { + Isin = cleanIsin, + CompanyName = !string.IsNullOrWhiteSpace(companyName) ? companyName : isinDoc.CompanyName, + Sector = !string.IsNullOrWhiteSpace(sector) ? sector : isinDoc.Sector, + LastUpdated = nowIso, + CurrentSummary = new IsinCurrentSummary + { + CompoundScore = Math.Round(avgCompound, 2), + SentimentLabel = label, + AvgConfidence = Math.Round(avgConf, 2), + TotalArticlesAnalyzed = updatedAnalyses.Count, + Text = textSummary + }, + Analyses = updatedAnalyses + }; + + await File.WriteAllTextAsync(filePath, JsonSerializer.Serialize(updatedDoc, _jsonOptions)); + _logger.LogInformation("[{Channel}] Updated ISIN summary file: {Path} (Total: {Count}, Score: {Score:F2})", "SentimentChannel", filePath, updatedAnalyses.Count, avgCompound); + } + finally + { + fileLock.Release(); + } + } + + /// + public async Task UpdateSectorSummaryAsync(string sector, string isin, string articleId, FinBertResultDto finbert) + { + if (string.IsNullOrWhiteSpace(sector)) return; + + string sanitizedSector = string.Concat(sector.Split(Path.GetInvalidFileNameChars())).Trim(); + string filePath = Path.Combine(_basePath, "sectors", $"{sanitizedSector}.json"); + + var fileLock = FileLocks.GetOrAdd(filePath, _ => new SemaphoreSlim(1, 1)); + await fileLock.WaitAsync(); + + try + { + SectorSentimentSummaryDto sectorDoc; + + if (File.Exists(filePath)) + { + try + { + string json = await File.ReadAllTextAsync(filePath); + sectorDoc = JsonSerializer.Deserialize(json, _jsonOptions) ?? new SectorSentimentSummaryDto { Sector = sector }; + } + catch + { + sectorDoc = new SectorSentimentSummaryDto { Sector = sector }; + } + } + else + { + sectorDoc = new SectorSentimentSummaryDto { Sector = sector }; + } + + 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 + { + AnalysisId = analysisId, + Timestamp = nowIso, + RelatedIsin = isin, + ArticleId = articleId, + FinbertResult = finbert + }; + + // Duplikate bereinigen (selber Artikel für denselben Sektor) + var updatedAnalyses = sectorDoc.Analyses? + .Where(a => !string.Equals(a.ArticleId, articleId, StringComparison.OrdinalIgnoreCase)) + .ToList() ?? new List(); + + updatedAnalyses.Insert(0, newEntry); + + if (updatedAnalyses.Count > 100) + { + updatedAnalyses = updatedAnalyses.Take(100).ToList(); + } + + var activeIsins = updatedAnalyses.Select(a => a.RelatedIsin).Where(i => !string.IsNullOrWhiteSpace(i)).Distinct().ToList(); + double avgCompound = updatedAnalyses.Average(a => a.FinbertResult.CompoundScore); + string label = CalculateLabel(avgCompound); + + string overviewText = label switch + { + "POSITIVE" => $"Der Sektor {sector} tendiert insgesamt positiv. Starke Einzelergebnisse stützen den Trend.", + "NEGATIVE" => $"Der Sektor {sector} verzeichnet dämpfende Sentiment-Signale.", + _ => $"Der Sektor {sector} zeigt ein ausgewogenes neutrales Gesamtbild." + }; + + var updatedDoc = sectorDoc with + { + Sector = sector, + LastUpdated = nowIso, + CurrentSummary = new SectorCurrentSummary + { + CompoundScore = Math.Round(avgCompound, 2), + SentimentLabel = label, + ActiveIsins = activeIsins, + Text = overviewText + }, + Analyses = updatedAnalyses + }; + + await File.WriteAllTextAsync(filePath, JsonSerializer.Serialize(updatedDoc, _jsonOptions)); + _logger.LogInformation("[{Channel}] Updated Sector summary file: {Path} (Active ISINs: {Count})", "SentimentChannel", filePath, activeIsins.Count); + } + finally + { + fileLock.Release(); + } + } + + private static string CalculateLabel(double score) => score switch + { + >= 0.15 => "POSITIVE", + <= -0.15 => "NEGATIVE", + _ => "NEUTRAL" + }; +} \ No newline at end of file diff --git a/FinlyticSentiment/Services/SettingsDbService.cs b/FinlyticSentiment/Services/SettingsDbService.cs new file mode 100644 index 0000000..8d99d9d --- /dev/null +++ b/FinlyticSentiment/Services/SettingsDbService.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using FinlyticSentiment.Database; +using FinlyticSentiment.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace FinlyticSentiment.Services; + +/// +/// Interface for reading and persisting FinlyticSentiment runtime configuration settings in PostgreSQL. +/// +public interface ISettingsDbService +{ + /// + /// Retrieves current sentiment settings from PostgreSQL database, seeding defaults if empty. + /// + Task GetSettingsAsync(); + + /// + /// Persists updated settings entity to PostgreSQL. + /// + Task SaveSettingsAsync(SentimentSettingsEntity settings); + + /// + /// Updates settings from a key-value dictionary received via Admin Panel MQTT events. + /// + Task UpdateSettingsFromDictionaryAsync(Dictionary dictionary); +} + +/// +/// EF Core PostgreSQL implementation of . +/// +public class SettingsDbService : ISettingsDbService +{ + private readonly SentimentDbContext _context; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + public SettingsDbService(SentimentDbContext context, ILogger logger) + { + _context = context; + _logger = logger; + } + + /// + /// Retrieves current sentiment settings from PostgreSQL database. + /// + public async Task GetSettingsAsync() + { + var settings = await _context.Settings.AsNoTracking().FirstOrDefaultAsync(); + if (settings == null) + { + settings = new SentimentSettingsEntity { Id = Guid.NewGuid() }; + _context.Settings.Add(settings); + await _context.SaveChangesAsync(); + _context.ChangeTracker.Clear(); + } + return settings; + } + + /// + /// Persists updated settings entity to PostgreSQL. + /// + public async Task SaveSettingsAsync(SentimentSettingsEntity settings) + { + var existing = await _context.Settings.FirstOrDefaultAsync(); + if (existing == null) + { + if (settings.Id == Guid.Empty) settings.Id = Guid.NewGuid(); + _context.Settings.Add(settings); + } + else + { + existing.MinConfidenceScore = settings.MinConfidenceScore; + existing.MaxBatchSize = settings.MaxBatchSize; + existing.SweepIntervalMinutes = settings.SweepIntervalMinutes; + existing.GermanWebhookUrl = settings.GermanWebhookUrl; + existing.EnglishWebhookUrl = settings.EnglishWebhookUrl; + existing.UpdatedAt = settings.UpdatedAt; + _context.Settings.Update(existing); + } + await _context.SaveChangesAsync(); + return settings; + } + + /// + /// Updates settings from a key-value dictionary. + /// + public async Task UpdateSettingsFromDictionaryAsync(Dictionary dictionary) + { + var settings = await GetSettingsAsync(); + + foreach (var (key, value) in dictionary) + { + if (string.Equals(key, "MinConfidenceScore", StringComparison.OrdinalIgnoreCase) && double.TryParse(value, out var mcs)) + settings.MinConfidenceScore = mcs; + else if (string.Equals(key, "MaxBatchSize", StringComparison.OrdinalIgnoreCase) && int.TryParse(value, out var mbs)) + settings.MaxBatchSize = mbs; + else if (string.Equals(key, "SweepIntervalMinutes", StringComparison.OrdinalIgnoreCase) && int.TryParse(value, out var sim)) + settings.SweepIntervalMinutes = sim; + else if (string.Equals(key, "GermanWebhookUrl", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(value)) + settings.GermanWebhookUrl = value.Trim(); + else if (string.Equals(key, "EnglishWebhookUrl", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(value)) + settings.EnglishWebhookUrl = value.Trim(); + } + + settings.UpdatedAt = DateTime.UtcNow; + await SaveSettingsAsync(settings); + _logger.LogInformation("[{Channel}] Successfully updated {Count} sentiment settings in PostgreSQL database.", "SentimentChannel", dictionary.Count); + } +} diff --git a/FinlyticSentiment/Util/SentimentMqttClient.cs b/FinlyticSentiment/Util/SentimentMqttClient.cs new file mode 100644 index 0000000..8c053c8 --- /dev/null +++ b/FinlyticSentiment/Util/SentimentMqttClient.cs @@ -0,0 +1,478 @@ +using System.Text.Json; +using FinlyticCore.Dtos; +using FinlyticCore.Dtos.News; +using FinlyticCore.Dtos.Sentiment; +using FinlyticCore.Models; +using FinlyticCore.Util; +using FinlyticSentiment.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace FinlyticSentiment.Util; + +/// +/// Managed MQTT client for requesting pending news articles and updating sentiment results. +/// +public class SentimentMqttClient : ManagedMqttClient, IHostedService +{ + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + private readonly IServiceScopeFactory _scopeFactory; + + /// + /// Initializes a new instance of the class. + /// + public SentimentMqttClient( + ILogger logger, + IConfiguration configuration, + IServiceScopeFactory scopeFactory) : base(logger) + { + _logger = logger; + _configuration = configuration; + _scopeFactory = scopeFactory; + } + + /// + /// Starts the MQTT client and connects to the broker. + /// + public async Task StartAsync(CancellationToken cancellationToken) + { + var config = new MqttConfiguration + { + Host = _configuration["MQTT:Host"] ?? _configuration["MQTT__Host"] ?? "localhost", + Port = Convert.ToInt32(_configuration["MQTT:Port"] ?? _configuration["MQTT__Port"] ?? "1883"), + ClientId = + $"{(_configuration["MQTT:ClientId"] ?? _configuration["MQTT__ClientId"] ?? "FinlyticSentiment")}_{Guid.NewGuid()}" + }; + + _logger.LogInformation("[{Channel}] Starting Sentiment MQTT client. Host: {Host}, ClientId: {ClientId}", + "SentimentChannel", config.Host, config.ClientId); + await ConnectAsync(config); + } + + /// + /// Stops the MQTT client and disconnects from the broker. + /// + public async Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("[{Channel}] Stopping Sentiment MQTT client and disconnecting.", "SentimentChannel"); + await DisconnectAsync(); + } + + /// + /// Event triggered when a real-time article is broadcasted on services/news/completed. + /// + public event Func? OnArticleReceived; + + /// + protected override async Task OnConnectedAsync() + { + _logger.LogInformation( + "[{Channel}] Sentiment MQTT client connected. Subscribing to RPC response and broadcast topics...", + "SentimentChannel"); + await SubscribeAsync("services/response/#"); + await SubscribeAsync("services/news/completed"); + await SubscribeAsync("services/request/sentiment_GetArticle/#"); + await SubscribeAsync("services/request/sentiment_GetIsin/#"); + await SubscribeAsync("services/request/sentiment_Analyze/#"); + await SubscribeAsync("services/request/health_Ping/#"); + await SubscribeAsync("services/config/updated/#"); + } + + /// + protected override async Task OnMessageReceivedAsync(string topic, string payload) + { + if (string.IsNullOrWhiteSpace(topic)) return; + + // 1. Config update events + if (topic.StartsWith("services/config/updated", StringComparison.OrdinalIgnoreCase)) + { + if (topic.EndsWith("FinlyticSentiment", StringComparison.OrdinalIgnoreCase)) + { + await OnConfigUpdatedAsync(payload); + } + + return; + } + + if (topic.Equals("services/news/completed", StringComparison.OrdinalIgnoreCase)) + { + await OnNewsCompletedAsync(payload); + return; + } + + // Extract correlationId from topic suffix (e.g. services/request/sentiment_GetArticle/{correlationId}) + var lastSlash = topic.LastIndexOf('/'); + if (lastSlash < 0 || lastSlash >= topic.Length - 1) return; + + var correlationId = topic.Substring(lastSlash + 1); + + // 2. Dispatch to specific channel handlers + if (topic.Contains("sentiment_GetArticle", StringComparison.OrdinalIgnoreCase)) + { + await OnSentimentGetArticleAsync(payload, correlationId); + } + else if (topic.Contains("sentiment_GetIsin", StringComparison.OrdinalIgnoreCase)) + { + await OnSentimentGetIsinAsync(payload, correlationId); + } + else if (topic.Contains("sentiment_Analyze", StringComparison.OrdinalIgnoreCase)) + { + await OnSentimentAnalyzeAsync(payload, correlationId); + } + else if (topic.Contains("health_Ping", StringComparison.OrdinalIgnoreCase)) + { + await OnHealthPingAsync(topic, correlationId); + } + } + + /// + /// Handles dynamic service config update events. + /// + private async Task OnConfigUpdatedAsync(string payload) + { + _logger.LogInformation("[{Channel}] [SentimentMqttClient] Received config update event for FinlyticSentiment.", + "SentimentChannel"); + try + { + using var doc = JsonDocument.Parse(payload); + if (doc.RootElement.TryGetProperty("settings", out var settingsProp)) + { + var dict = JsonSerializer.Deserialize(settingsProp.GetRawText(), typeof(Dictionary), + FinlyticJsonSerializerContext.Default) as Dictionary; + if (dict != null && dict.Count > 0) + { + using var scope = _scopeFactory.CreateScope(); + var settingsDb = scope.ServiceProvider.GetRequiredService(); + await settingsDb.UpdateSettingsFromDictionaryAsync(dict); + _logger.LogInformation( + "[{Channel}] [SentimentMqttClient] Successfully persisted {Count} updated settings for FinlyticSentiment.", + "SentimentChannel", dict.Count); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "[{Channel}] [SentimentMqttClient] Error processing MQTT config update event.", + "SentimentChannel"); + } + } + + /// + /// Handles health_Ping RPC requests. + /// + private async Task OnHealthPingAsync(string topic, string correlationId) + { + if (topic.Contains("FinlyticSentiment", StringComparison.OrdinalIgnoreCase) || + !topic.Contains("/", StringComparison.OrdinalIgnoreCase)) + { + string respTopic = $"services/response/health_Ping/{correlationId}"; + await PublishAsync(respTopic, + new ServiceHealthResponse("FinlyticSentiment", "Online", DateTime.UtcNow, "Connected")); + _logger.LogInformation( + "[{Channel}] [SentimentMqttClient] Responded to live health_Ping RPC request [CorrelationId: {CorrelationId}].", + "SentimentChannel", correlationId); + } + } + + /// + /// Handles broadcasted articles on services/news/completed. + /// + private async Task OnNewsCompletedAsync(string payload) + { + if (string.IsNullOrWhiteSpace(payload)) return; + try + { + var article = + JsonSerializer.Deserialize(payload, typeof(NewsArticleDto), FinlyticJsonSerializerContext.Default) as + NewsArticleDto; + if (article != null && article.Id != Guid.Empty && OnArticleReceived != null) + { + _logger.LogInformation( + "[{Channel}] Received real-time article broadcast on services/news/completed: {Title} (ID: {Id})", + "SentimentChannel", article.Title, article.Id); + await OnArticleReceived.Invoke(article); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "[{Channel}] Error parsing broadcasted article on services/news/completed.", + "SentimentChannel"); + } + } + + /// + /// Handles sentiment_GetArticle RPC requests using source-generated DTO deserialization. + /// + 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; + + try + { + var request = + JsonSerializer.Deserialize(payload, typeof(ArticleRequest), FinlyticJsonSerializerContext.Default) as + ArticleRequest; + var articleId = request?.ArticleId ?? request?.Id; + + if (string.IsNullOrWhiteSpace(articleId)) + { + _logger.LogWarning("[{Channel}] [SentimentMqttClient] Missing articleId in request payload.", + "SentimentChannel"); + return; + } + + using var scope = _scopeFactory.CreateScope(); + var storageService = scope.ServiceProvider.GetRequiredService(); + var sentimentEntry = await storageService.GetArticleSentimentAsync(articleId); + + string responseTopic = $"services/response/sentiment_GetArticle/{correlationId}"; + _logger.LogInformation( + "[{Channel}] [SentimentMqttClient] Publishing RPC sentiment_GetArticle response for article {ArticleId} to {ResponseTopic}", + "SentimentChannel", articleId, responseTopic); + await PublishAsync(responseTopic, sentimentEntry); + } + catch (Exception ex) + { + _logger.LogError(ex, + "[{Channel}] [SentimentMqttClient] Failed to process sentiment_GetArticle RPC request.", + "SentimentChannel"); + } + } + + /// + /// Handles sentiment_GetIsin RPC requests using source-generated DTO deserialization. + /// + 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; + + try + { + var request = + JsonSerializer.Deserialize(payload, typeof(IsinRequest), FinlyticJsonSerializerContext.Default) as + IsinRequest; + var isin = request?.Isin; + + if (string.IsNullOrWhiteSpace(isin)) + { + _logger.LogWarning("[{Channel}] [SentimentMqttClient] Missing ISIN in request payload.", + "SentimentChannel"); + return; + } + + using var scope = _scopeFactory.CreateScope(); + var storageService = scope.ServiceProvider.GetRequiredService(); + var isinSummary = await storageService.GetIsinSummaryAsync(isin); + + string responseTopic = $"services/response/sentiment_GetIsin/{correlationId}"; + _logger.LogInformation( + "[{Channel}] [SentimentMqttClient] Publishing RPC sentiment_GetIsin response for ISIN {Isin} to {ResponseTopic}", + "SentimentChannel", isin, responseTopic); + await PublishAsync(responseTopic, isinSummary); + } + catch (Exception ex) + { + _logger.LogError(ex, "[{Channel}] [SentimentMqttClient] Failed to process sentiment_GetIsin RPC request.", + "SentimentChannel"); + } + } + + /// + /// Handles manual/forced sentiment_Analyze RPC requests using existing analyzer and storage services. + /// + private async Task OnSentimentAnalyzeAsync(string payload, string correlationId) + { + _logger.LogInformation( + "[{Channel}] [SentimentMqttClient] Processing RPC sentiment_Analyze request [CorrelationId: {CorrelationId}]", + "SentimentChannel", correlationId); + string responseTopic = $"services/response/sentiment_Analyze/{correlationId}"; + + if (string.IsNullOrWhiteSpace(payload)) + { + await PublishAsync(responseTopic, (object?)null); + return; + } + + try + { + var request = + JsonSerializer.Deserialize(payload, typeof(AnalyzeSentimentRequest), + FinlyticJsonSerializerContext.Default) as AnalyzeSentimentRequest; + + if (request == null || + (string.IsNullOrWhiteSpace(request.ArticleId) && string.IsNullOrWhiteSpace(request.Isin))) + { + _logger.LogWarning( + "[{Channel}] [SentimentMqttClient] Missing ArticleId or Isin in sentiment_Analyze request payload.", + "SentimentChannel"); + await PublishAsync(responseTopic, (object?)null); + return; + } + + using var scope = _scopeFactory.CreateScope(); + var storageService = scope.ServiceProvider.GetRequiredService(); + var analyzerService = scope.ServiceProvider.GetRequiredService(); + + object? result = null; + + // Fall 1: Manuelle Analyse für einen einzelnen Artikel + if (!string.IsNullOrWhiteSpace(request.ArticleId)) + { + var cleanArticleId = request.ArticleId.Trim(); + _logger.LogInformation( + "[{Channel}] [SentimentMqttClient] Processing article analysis for ArticleId: {ArticleId} (ForceReload: {ForceReload})", + "SentimentChannel", cleanArticleId, request.ForceReload); + + IsinAnalysisEntry? existingEntry = null; + + if (!request.ForceReload) + { + existingEntry = await storageService.GetArticleSentimentAsync(cleanArticleId); + } + + if (existingEntry != null) + { + result = existingEntry; + } + else + { + // Artikel per RPC von FinlyticNews abfragen + if (Guid.TryParse(cleanArticleId, out var articleGuid)) + { + var article = await SendRpcRequestAsync( + "news_GetById", + new ArticleRequest(cleanArticleId, cleanArticleId), + TimeSpan.FromSeconds(8)); + + if (article != null) + { + var finbertResult = await analyzerService.AnalyzeArticleAsync(article); + + // 🎯 NULL-CHECK: Falls Analyse fehlschlägt/null liefert -> abbrechen + if (finbertResult == null) + { + _logger.LogWarning( + "[{Channel}] [SentimentMqttClient] FinBERT analysis returned NULL for article {ArticleId}. Aborting manual analysis.", + "SentimentChannel", cleanArticleId); + await PublishAsync(responseTopic, (object?)null); + return; + } + + // Speichern & Summaries aktualisieren + await storageService.SaveArticleSentimentAsync(article, finbertResult); + + if (article.MatchedAssets != null && article.MatchedAssets.Count > 0) + { + foreach (var asset in article.MatchedAssets) + { + if (string.IsNullOrWhiteSpace(asset.Isin)) continue; + + await storageService.UpdateIsinSummaryAsync( + asset.Isin, + asset.Name, + "General", + article, + finbertResult); + + await storageService.UpdateSectorSummaryAsync( + "General", + asset.Isin, + article.Id.ToString(), + finbertResult); + } + } + + // FinlyticNews über Re-Analyse informieren + await UpdateArticleStatusAsync(article.Id, "Analyzed"); + + result = await storageService.GetArticleSentimentAsync(cleanArticleId); + } + else + { + _logger.LogWarning( + "[{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)) + { + var cleanIsin = request.Isin.Trim(); + _logger.LogInformation("[{Channel}] [SentimentMqttClient] Fetching sentiment summary for ISIN: {Isin}", + "SentimentChannel", cleanIsin); + + result = await storageService.GetIsinSummaryAsync(cleanIsin); + } + + _logger.LogInformation( + "[{Channel}] [SentimentMqttClient] Publishing RPC sentiment_Analyze response to {ResponseTopic}", + "SentimentChannel", responseTopic); + await PublishAsync(responseTopic, result); + } + catch (Exception ex) + { + _logger.LogError(ex, "[{Channel}] [SentimentMqttClient] Failed to process sentiment_Analyze RPC request.", + "SentimentChannel"); + await PublishAsync(responseTopic, (object?)null); + } + } + + /// + /// Requests pending news articles from FinlyticNews via MQTT RPC. + /// + /// The maximum number of articles to request (capped at 10). + /// A list of pending news article DTOs. + public async Task> GetPendingArticlesAsync(int limit = 10) + { + try + { + var payload = new LimitRequest(Math.Min(limit, 10)); + var articles = + await SendRpcRequestAsync, LimitRequest>("news_GetPending", payload, + TimeSpan.FromSeconds(10)); + return articles ?? []; + } + catch (Exception ex) + { + _logger.LogError(ex, "[{Channel}] Error executing MQTT RPC for news_GetPending.", "SentimentChannel"); + } + + return []; + } + + /// + /// Dispatches an RPC request to update the article status in FinlyticNews (e.g. to "Analyzed"). + /// + /// The article identifier. + /// The target status string (default "Analyzed"). + /// True if the status update succeeded; otherwise, false. + public async Task UpdateArticleStatusAsync(Guid id, string status = "Analyzed") + { + try + { + var request = new UpdateNewsStatusRequest(id, status); + var response = + await SendRpcRequestAsync("news_UpdateStatus", + request, TimeSpan.FromSeconds(8)); + return response?.Success ?? false; + } + catch (Exception ex) + { + _logger.LogError(ex, "[{Channel}] Error executing MQTT RPC for news_UpdateStatus (ID: {Id}).", + "SentimentChannel", id); + } + + return false; + } +} \ No newline at end of file diff --git a/FinlyticSentiment/appsettings.json b/FinlyticSentiment/appsettings.json new file mode 100644 index 0000000..aa793e8 --- /dev/null +++ b/FinlyticSentiment/appsettings.json @@ -0,0 +1,24 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Warning" + } + }, + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Database=finlytic_sentiment;Username=admin;Password=admin" + }, + "MQTT": { + "Host": "localhost", + "Port": 1883, + "ClientId": "FinlyticSentiment" + }, + "Storage": { + "SummariesPath": "data/summaries" + }, + "Webhooks": { + "German": "https://n8n.kleidukos.me/webhook/sentiment/de", + "English": "https://n8n.kleidukos.me/webhook/sentiment/en" + } +}