feat(Sentiment): update sentiment service

This commit is contained in:
2026-08-09 21:01:42 +02:00
parent aff831cf58
commit 05d55a324c
15 changed files with 1772 additions and 0 deletions
@@ -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);
}
}