feat(Sentiment): update sentiment service
This commit is contained in:
@@ -0,0 +1,29 @@
|
|||||||
|
using FinlyticSentiment.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FinlyticSentiment.Database;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// EF Core DbContext for managing FinlyticSentiment settings in PostgreSQL.
|
||||||
|
/// </summary>
|
||||||
|
public class SentimentDbContext : DbContext
|
||||||
|
{
|
||||||
|
public SentimentDbContext(DbContextOptions<SentimentDbContext> options) : base(options)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public DbSet<SentimentSettingsEntity> Settings => Set<SentimentSettingsEntity>();
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SentimentSettingsEntity>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("sentiment_settings");
|
||||||
|
entity.HasKey(e => e.Id);
|
||||||
|
entity.Property(e => e.GermanWebhookUrl).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.EnglishWebhookUrl).HasMaxLength(500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace FinlyticSentiment.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Entity representing runtime operational settings for FinlyticSentiment stored in PostgreSQL.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.1" />
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\FinlyticCore\FinlyticCore.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
// <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("20260801090401_InitialSentimentSettings")]
|
||||||
|
partial class InitialSentimentSettings
|
||||||
|
{
|
||||||
|
/// <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("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,39 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace FinlyticSentiment.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class InitialSentimentSettings : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "sentiment_settings",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
MinConfidenceScore = table.Column<double>(type: "double precision", nullable: false),
|
||||||
|
MaxBatchSize = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
SweepIntervalMinutes = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
GermanWebhookUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||||
|
EnglishWebhookUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_sentiment_settings", x => x.Id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "sentiment_settings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
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<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,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<SentimentDbContext>(options =>
|
||||||
|
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||||
|
|
||||||
|
// Register HttpClient
|
||||||
|
builder.Services.AddHttpClient();
|
||||||
|
|
||||||
|
// Register Service interfaces and co-located implementations
|
||||||
|
builder.Services.AddScoped<ISettingsDbService, SettingsDbService>();
|
||||||
|
builder.Services.AddSingleton<IFinBertAnalyzerService, FinBertAnalyzerService>();
|
||||||
|
builder.Services.AddSingleton<ISentimentStorageService, SentimentStorageService>();
|
||||||
|
|
||||||
|
// Register MQTT Client (as singleton hosted service)
|
||||||
|
builder.Services.AddSingleton<SentimentMqttClient>();
|
||||||
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<SentimentMqttClient>());
|
||||||
|
|
||||||
|
// Register Sentiment Background Worker
|
||||||
|
builder.Services.AddHostedService<SentimentBackgroundService>();
|
||||||
|
|
||||||
|
var host = builder.Build();
|
||||||
|
|
||||||
|
// Auto-migrate database on startup
|
||||||
|
using (var scope = host.Services.CreateScope())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<SentimentDbContext>();
|
||||||
|
await db.Database.MigrateAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
|
||||||
|
logger.LogError(ex, "An error occurred during database migration for FinlyticSentiment on startup.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await host.RunAsync();
|
||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the sentiment analysis contract for evaluating news articles via FinBERT webhooks.
|
||||||
|
/// </summary>
|
||||||
|
public interface IFinBertAnalyzerService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Analyzes a news article using language-targeted FinBERT webhooks and returns structured metrics.
|
||||||
|
/// </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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implementation of the sentiment analysis contract for evaluating news articles via FinBERT webhooks.
|
||||||
|
/// </summary>
|
||||||
|
public class FinBertAnalyzerService : IFinBertAnalyzerService
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly ILogger<FinBertAnalyzerService> _logger;
|
||||||
|
|
||||||
|
/// <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(
|
||||||
|
HttpClient httpClient,
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
ILogger<FinBertAnalyzerService> logger)
|
||||||
|
{
|
||||||
|
_httpClient = httpClient;
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Analyzes a news article using language-targeted FinBERT webhooks and returns structured metrics.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<FinBertResultDto?> AnalyzeArticleAsync(NewsArticleDto article)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(article);
|
||||||
|
|
||||||
|
string targetUrl;
|
||||||
|
double minConfidence;
|
||||||
|
using (var scope = _scopeFactory.CreateScope())
|
||||||
|
{
|
||||||
|
var settingsDb = scope.ServiceProvider.GetRequiredService<ISettingsDbService>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Background hosted worker executing periodic sentiment analysis sweeps on pending news articles
|
||||||
|
/// and processing real-time article broadcasts.
|
||||||
|
/// </summary>
|
||||||
|
public class SentimentBackgroundService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly SentimentMqttClient _mqttClient;
|
||||||
|
private readonly IFinBertAnalyzerService _analyzer;
|
||||||
|
private readonly ISentimentStorageService _storage;
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly ILogger<SentimentBackgroundService> _logger;
|
||||||
|
|
||||||
|
// In-Memory Mutex / Cache zur Vermeidung doppelter Verarbeitung (Race Conditions zwischen Broadcast & Sweep)
|
||||||
|
private static readonly ConcurrentDictionary<Guid, byte> ProcessingArticles = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="SentimentBackgroundService"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public SentimentBackgroundService(
|
||||||
|
SentimentMqttClient mqttClient,
|
||||||
|
IFinBertAnalyzerService analyzer,
|
||||||
|
ISentimentStorageService storage,
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
ILogger<SentimentBackgroundService> logger)
|
||||||
|
{
|
||||||
|
_mqttClient = mqttClient;
|
||||||
|
_analyzer = analyzer;
|
||||||
|
_storage = storage;
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<ISettingsDbService>();
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Performs a single batch sweep of pending news articles.
|
||||||
|
/// </summary>
|
||||||
|
private async Task PerformSentimentSweepAsync(int maxBatchSize, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("[{Channel}] Starting sentiment sweep for pending news articles (Limit: {Limit})...",
|
||||||
|
"SentimentChannel", maxBatchSize);
|
||||||
|
|
||||||
|
List<NewsArticleDto> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Processes FinBERT sentiment analysis for a single article, updates two-stage JSON summaries, and notifies FinlyticNews of status update.
|
||||||
|
/// </summary>
|
||||||
|
private async Task ProcessSingleArticleAsync(NewsArticleDto article, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (article == null || article.Id == Guid.Empty) return;
|
||||||
|
|
||||||
|
// Deduplizierung: Verhindert, dass derselbe Artikel zeitgleich im Sweep & im Broadcast verarbeitet wird
|
||||||
|
if (!ProcessingArticles.TryAdd(article.Id, 0))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("[{Channel}] Article {Id} is already being processed. Skipping duplicate run.",
|
||||||
|
"SentimentChannel", article.Id);
|
||||||
|
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 _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines the persistence contract for maintaining two-stage ISIN and Sector JSON sentiment summaries in the file system.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists an article's sentiment analysis directly in data/summaries/articles/{articleId}.json.
|
||||||
|
/// </summary>
|
||||||
|
Task SaveArticleSentimentAsync(NewsArticleDto article, FinBertResultDto finbert);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves the sentiment analysis entry for a specific news article.
|
||||||
|
/// </summary>
|
||||||
|
Task<IsinAnalysisEntry?> GetArticleSentimentAsync(string articleId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves the aggregate sentiment summary for a specific asset ISIN.
|
||||||
|
/// </summary>
|
||||||
|
Task<IsinSentimentSummaryDto?> GetIsinSummaryAsync(string isin);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SentimentStorageService : ISentimentStorageService
|
||||||
|
{
|
||||||
|
private static readonly ConcurrentDictionary<string, SemaphoreSlim> FileLocks = new();
|
||||||
|
|
||||||
|
private readonly ILogger<SentimentStorageService> _logger;
|
||||||
|
private readonly string _basePath;
|
||||||
|
private readonly JsonSerializerOptions _jsonOptions;
|
||||||
|
|
||||||
|
public SentimentStorageService(IConfiguration configuration, ILogger<SentimentStorageService> 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IsinAnalysisEntry?> 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<IsinAnalysisEntry>(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<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IsinSentimentSummaryDto?> 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<IsinSentimentSummaryDto>(json, _jsonOptions);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "[{Channel}] Error reading ISIN summary file: {Path}", "SentimentChannel", filePath);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
fileLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<IsinSentimentSummaryDto>(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<IsinAnalysisEntry>();
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<SectorSentimentSummaryDto>(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<SectorAnalysisEntry>();
|
||||||
|
|
||||||
|
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"
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Interface for reading and persisting FinlyticSentiment runtime configuration settings in PostgreSQL.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISettingsDbService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves current sentiment settings from PostgreSQL database, seeding defaults if empty.
|
||||||
|
/// </summary>
|
||||||
|
Task<SentimentSettingsEntity> GetSettingsAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists updated settings entity to PostgreSQL.
|
||||||
|
/// </summary>
|
||||||
|
Task<SentimentSettingsEntity> SaveSettingsAsync(SentimentSettingsEntity settings);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates settings from a key-value dictionary received via Admin Panel MQTT events.
|
||||||
|
/// </summary>
|
||||||
|
Task UpdateSettingsFromDictionaryAsync(Dictionary<string, string> dictionary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// EF Core PostgreSQL implementation of <see cref="ISettingsDbService"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class SettingsDbService : ISettingsDbService
|
||||||
|
{
|
||||||
|
private readonly SentimentDbContext _context;
|
||||||
|
private readonly ILogger<SettingsDbService> _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="SettingsDbService"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public SettingsDbService(SentimentDbContext context, ILogger<SettingsDbService> logger)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves current sentiment settings from PostgreSQL database.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<SentimentSettingsEntity> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists updated settings entity to PostgreSQL.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<SentimentSettingsEntity> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates settings from a key-value dictionary.
|
||||||
|
/// </summary>
|
||||||
|
public async Task UpdateSettingsFromDictionaryAsync(Dictionary<string, string> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Managed MQTT client for requesting pending news articles and updating sentiment results.
|
||||||
|
/// </summary>
|
||||||
|
public class SentimentMqttClient : ManagedMqttClient, IHostedService
|
||||||
|
{
|
||||||
|
private readonly ILogger<SentimentMqttClient> _logger;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="SentimentMqttClient"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public SentimentMqttClient(
|
||||||
|
ILogger<SentimentMqttClient> logger,
|
||||||
|
IConfiguration configuration,
|
||||||
|
IServiceScopeFactory scopeFactory) : base(logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_configuration = configuration;
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts the MQTT client and connects to the broker.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops the MQTT client and disconnects from the broker.
|
||||||
|
/// </summary>
|
||||||
|
public async Task StopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("[{Channel}] Stopping Sentiment MQTT client and disconnecting.", "SentimentChannel");
|
||||||
|
await DisconnectAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Event triggered when a real-time article is broadcasted on services/news/completed.
|
||||||
|
/// </summary>
|
||||||
|
public event Func<NewsArticleDto, Task>? OnArticleReceived;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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/#");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles dynamic service config update events.
|
||||||
|
/// </summary>
|
||||||
|
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<string, string>),
|
||||||
|
FinlyticJsonSerializerContext.Default) as Dictionary<string, string>;
|
||||||
|
if (dict != null && dict.Count > 0)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var settingsDb = scope.ServiceProvider.GetRequiredService<ISettingsDbService>();
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles health_Ping RPC requests.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles broadcasted articles on services/news/completed.
|
||||||
|
/// </summary>
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles sentiment_GetArticle RPC requests using source-generated DTO deserialization.
|
||||||
|
/// </summary>
|
||||||
|
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<ISentimentStorageService>();
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles sentiment_GetIsin RPC requests using source-generated DTO deserialization.
|
||||||
|
/// </summary>
|
||||||
|
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<ISentimentStorageService>();
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles manual/forced sentiment_Analyze RPC requests using existing analyzer and storage services.
|
||||||
|
/// </summary>
|
||||||
|
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<ISentimentStorageService>();
|
||||||
|
var analyzerService = scope.ServiceProvider.GetRequiredService<IFinBertAnalyzerService>();
|
||||||
|
|
||||||
|
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<NewsArticleDto, ArticleRequest>(
|
||||||
|
"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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var payload = new LimitRequest(Math.Min(limit, 10));
|
||||||
|
var articles =
|
||||||
|
await SendRpcRequestAsync<List<NewsArticleDto>, 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 [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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")
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = new UpdateNewsStatusRequest(id, status);
|
||||||
|
var response =
|
||||||
|
await SendRpcRequestAsync<UpdateNewsStatusResponse, UpdateNewsStatusRequest>("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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user