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

This commit is contained in:
2026-08-15 21:30:12 +02:00
parent 1522c3480f
commit 62e030e2cf
10 changed files with 519 additions and 397 deletions
@@ -1,10 +1,17 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos.News;
using FinlyticCore.Dtos.Sentiment;
using FinlyticCore.Services;
using FinlyticSentiment.Util;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace FinlyticSentiment.Services;
@@ -13,40 +20,10 @@ namespace FinlyticSentiment.Services;
/// </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);
}
@@ -54,13 +31,13 @@ public class SentimentStorageService : ISentimentStorageService
{
private static readonly ConcurrentDictionary<string, SemaphoreSlim> FileLocks = new();
private readonly ILogger<SentimentStorageService> _logger;
private readonly IFinlyticLogger<SentimentStorageService> _finlyticLogger;
private readonly string _basePath;
private readonly JsonSerializerOptions _jsonOptions;
public SentimentStorageService(IConfiguration configuration, ILogger<SentimentStorageService> logger)
public SentimentStorageService(IConfiguration configuration, IFinlyticLogger<SentimentStorageService> finlyticLogger)
{
_logger = logger;
_finlyticLogger = finlyticLogger;
_basePath = configuration["Storage:SummariesPath"] ?? "data/summaries";
Directory.CreateDirectory(Path.Combine(_basePath, "isin"));
@@ -88,30 +65,28 @@ public class SentimentStorageService : ISentimentStorageService
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,
AnalysisId = $"sent_{Guid.NewGuid():N}",
Timestamp = nowIso,
Article = new IsinAnalysisArticleRef
{
ArticleId = cleanId,
Title = article.Title ?? "",
Source = article.Author ?? "FinlyticNews",
PublishedAt = article.PublishedAt.ToString("yyyy-MM-ddTHH:mm:ssZ")
Title = article.Title,
PublishedAt = article.PublishedAt.ToString("yyyy-MM-ddTHH:mm:ssZ"),
Source = article.Author ?? "FinlyticNews"
},
FinbertResult = finbert,
SummarySnippet = finbert.SummarySnippet ?? article.Summary ?? article.Title ?? ""
SummarySnippet = finbert.SummarySnippet ?? article.Summary ?? string.Empty
};
string json = JsonSerializer.Serialize(entry, _jsonOptions);
var json = JsonSerializer.Serialize(entry, _jsonOptions);
await File.WriteAllTextAsync(filePath, json);
_logger.LogInformation("[{Channel}] Successfully saved article sentiment file: {Path}", "SentimentChannel", filePath);
await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentStorageService] Successfully saved article sentiment file: {Path}", filePath);
}
catch (Exception ex)
{
_logger.LogError(ex, "[{Channel}] Failed to write article sentiment file: {Path}", "SentimentChannel", filePath);
await _finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[SentimentStorageService] Failed to write article sentiment file: {Path}", filePath);
}
finally
{
@@ -124,10 +99,9 @@ public class SentimentStorageService : ISentimentStorageService
{
if (string.IsNullOrWhiteSpace(articleId)) return null;
var cleanId = articleId.Trim();
var articleFilePath = Path.Combine(_basePath, "articles", $"{cleanId}.json");
string cleanId = articleId.Trim();
string articleFilePath = Path.Combine(_basePath, "articles", $"{cleanId}.json");
// 1. Primärer Lookup
if (File.Exists(articleFilePath))
{
var fileLock = FileLocks.GetOrAdd(articleFilePath, _ => new SemaphoreSlim(1, 1));
@@ -139,7 +113,7 @@ public class SentimentStorageService : ISentimentStorageService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[{Channel}] Failed to read article sentiment file: {Path}", "SentimentChannel", articleFilePath);
await _finlyticLogger.LogWarningAsync(SettingKeys.SentimentChannel, ex, "[SentimentStorageService] Failed to read article sentiment file: {Path}", articleFilePath);
}
finally
{
@@ -147,22 +121,6 @@ public class SentimentStorageService : ISentimentStorageService
}
}
// 2. Fallback in ISIN-Dateien
var dirPath = Path.Combine(_basePath, "isin");
if (!Directory.Exists(dirPath)) return null;
foreach (var file in Directory.GetFiles(dirPath, "*.json"))
{
try
{
var json = await File.ReadAllTextAsync(file);
var doc = JsonSerializer.Deserialize<IsinSentimentSummaryDto>(json, _jsonOptions);
var match = doc?.Analyses?.FirstOrDefault(a => string.Equals(a.Article?.ArticleId?.Trim(), cleanId, StringComparison.OrdinalIgnoreCase));
if (match != null) return match;
}
catch { }
}
return null;
}
@@ -171,8 +129,8 @@ public class SentimentStorageService : ISentimentStorageService
{
if (string.IsNullOrWhiteSpace(isin)) return null;
var cleanIsin = isin.Trim();
var filePath = Path.Combine(_basePath, "isin", $"{cleanIsin}.json");
string cleanIsin = isin.Trim().ToUpperInvariant();
string filePath = Path.Combine(_basePath, "isin", $"{cleanIsin}.json");
if (!File.Exists(filePath)) return null;
@@ -186,7 +144,7 @@ public class SentimentStorageService : ISentimentStorageService
}
catch (Exception ex)
{
_logger.LogError(ex, "[{Channel}] Error reading ISIN summary file: {Path}", "SentimentChannel", filePath);
await _finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[SentimentStorageService] Error reading ISIN summary file: {Path}", filePath);
return null;
}
finally
@@ -198,9 +156,9 @@ public class SentimentStorageService : ISentimentStorageService
/// <inheritdoc />
public async Task UpdateIsinSummaryAsync(string isin, string companyName, string sector, NewsArticleDto article, FinBertResultDto finbert)
{
if (string.IsNullOrWhiteSpace(isin) || article == null) return;
if (string.IsNullOrWhiteSpace(isin)) return;
string cleanIsin = isin.Trim();
string cleanIsin = isin.Trim().ToUpperInvariant();
string filePath = Path.Combine(_basePath, "isin", $"{cleanIsin}.json");
var fileLock = FileLocks.GetOrAdd(filePath, _ => new SemaphoreSlim(1, 1));
@@ -208,87 +166,92 @@ public class SentimentStorageService : ISentimentStorageService
try
{
IsinSentimentSummaryDto isinDoc;
var analyses = new List<IsinAnalysisEntry>();
if (File.Exists(filePath))
{
try
var existingJson = await File.ReadAllTextAsync(filePath);
var existing = JsonSerializer.Deserialize<IsinSentimentSummaryDto>(existingJson, _jsonOptions);
if (existing?.Analyses != null)
{
string json = await File.ReadAllTextAsync(filePath);
isinDoc = JsonSerializer.Deserialize<IsinSentimentSummaryDto>(json, _jsonOptions) ?? new IsinSentimentSummaryDto { Isin = cleanIsin };
}
catch
{
isinDoc = new IsinSentimentSummaryDto { Isin = cleanIsin };
analyses.AddRange(existing.Analyses);
}
}
else
{
isinDoc = new IsinSentimentSummaryDto { Isin = cleanIsin };
}
string cleanArticleId = article.Id.ToString();
analyses.RemoveAll(a => string.Equals(a.Article?.ArticleId, cleanArticleId, StringComparison.OrdinalIgnoreCase));
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,
AnalysisId = $"sent_{Guid.NewGuid():N}",
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")
ArticleId = cleanArticleId,
Title = article.Title,
PublishedAt = article.PublishedAt.ToString("yyyy-MM-ddTHH:mm:ssZ"),
Source = article.Author ?? "FinlyticNews"
},
FinbertResult = finbert,
SummarySnippet = finbert.SummarySnippet ?? article.Summary ?? article.Title ?? ""
SummarySnippet = finbert.SummarySnippet ?? article.Summary ?? string.Empty
};
// Duplikat-Bereinigung: Falls Artikel bereits existiert, alten Eintrag entfernen!
var updatedAnalyses = isinDoc.Analyses?
.Where(a => !string.Equals(a.Article?.ArticleId, article.Id.ToString(), StringComparison.OrdinalIgnoreCase))
.ToList() ?? new List<IsinAnalysisEntry>();
analyses.Add(newEntry);
// Neuen Eintrag oben einfügen
updatedAnalyses.Insert(0, newEntry);
// Capping: Maximal die letzten 100 Analysen aufheben (verhindert gigantische JSON-Dateien)
if (updatedAnalyses.Count > 100)
var cutoff = DateTime.UtcNow.AddDays(-14);
var validAnalyses = analyses.Where(a =>
{
updatedAnalyses = updatedAnalyses.Take(100).ToList();
if (DateTime.TryParse(a.Article?.PublishedAt ?? a.Timestamp, out var pubDate))
{
return pubDate >= cutoff;
}
return true;
}).ToList();
var updatedAnalyses = validAnalyses.OrderByDescending(a => a.Article?.PublishedAt ?? a.Timestamp).Take(50).ToList();
double totalCompound = 0.0;
double totalConf = 0.0;
foreach (var item in updatedAnalyses)
{
if (item.FinbertResult == null) continue;
totalCompound += item.FinbertResult.CompoundScore;
totalConf += item.FinbertResult.Confidence;
}
double avgCompound = updatedAnalyses.Average(a => a.FinbertResult.CompoundScore);
double avgConf = updatedAnalyses.Average(a => a.FinbertResult.Confidence);
string label = CalculateLabel(avgCompound);
int total = updatedAnalyses.Count;
double avgCompound = total > 0 ? totalCompound / total : 0.0;
double avgConf = total > 0 ? totalConf / total : 0.0;
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."
};
string overallLabel = "NEUTRAL";
if (avgCompound >= 0.15) overallLabel = "POSITIVE";
else if (avgCompound <= -0.15) overallLabel = "NEGATIVE";
var updatedDoc = isinDoc with
var summary = new IsinSentimentSummaryDto
{
Isin = cleanIsin,
CompanyName = !string.IsNullOrWhiteSpace(companyName) ? companyName : isinDoc.CompanyName,
Sector = !string.IsNullOrWhiteSpace(sector) ? sector : isinDoc.Sector,
CompanyName = companyName,
Sector = sector,
LastUpdated = nowIso,
CurrentSummary = new IsinCurrentSummary
{
CompoundScore = Math.Round(avgCompound, 2),
SentimentLabel = label,
AvgConfidence = Math.Round(avgConf, 2),
TotalArticlesAnalyzed = updatedAnalyses.Count,
Text = textSummary
CompoundScore = Math.Round(avgCompound, 4),
SentimentLabel = overallLabel,
AvgConfidence = Math.Round(avgConf, 4),
TotalArticlesAnalyzed = total,
Text = $"Synthesized sentiment across {total} articles is {overallLabel}."
},
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);
var outJson = JsonSerializer.Serialize(summary, _jsonOptions);
await File.WriteAllTextAsync(filePath, outJson);
await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentStorageService] Updated ISIN summary file: {Path} (Total: {Count}, Score: {Score:F2})", filePath, updatedAnalyses.Count, avgCompound);
}
catch (Exception ex)
{
await _finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[SentimentStorageService] Failed to update ISIN summary for {Isin}", cleanIsin);
}
finally
{
@@ -301,95 +264,82 @@ public class SentimentStorageService : ISentimentStorageService
{
if (string.IsNullOrWhiteSpace(sector)) return;
string sanitizedSector = string.Concat(sector.Split(Path.GetInvalidFileNameChars())).Trim();
string filePath = Path.Combine(_basePath, "sectors", $"{sanitizedSector}.json");
string cleanSector = sector.Trim().ToLowerInvariant();
string filePath = Path.Combine(_basePath, "sectors", $"{cleanSector}.json");
var fileLock = FileLocks.GetOrAdd(filePath, _ => new SemaphoreSlim(1, 1));
await fileLock.WaitAsync();
try
{
SectorSentimentSummaryDto sectorDoc;
var analyses = new List<SectorAnalysisEntry>();
if (File.Exists(filePath))
{
try
var existingJson = await File.ReadAllTextAsync(filePath);
var existing = JsonSerializer.Deserialize<SectorSentimentSummaryDto>(existingJson, _jsonOptions);
if (existing?.Analyses != null)
{
string json = await File.ReadAllTextAsync(filePath);
sectorDoc = JsonSerializer.Deserialize<SectorSentimentSummaryDto>(json, _jsonOptions) ?? new SectorSentimentSummaryDto { Sector = sector };
analyses.AddRange(existing.Analyses);
}
catch
{
sectorDoc = new SectorSentimentSummaryDto { Sector = sector };
}
}
else
{
sectorDoc = new SectorSentimentSummaryDto { Sector = sector };
}
string cleanIsin = isin.Trim().ToUpperInvariant();
string nowIso = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ");
string analysisId = $"sec_sent_{DateTime.UtcNow:yyyyMMdd}_{Random.Shared.Next(100, 999)}";
var newEntry = new SectorAnalysisEntry
analyses.RemoveAll(a => string.Equals(a.ArticleId, articleId, StringComparison.OrdinalIgnoreCase) && string.Equals(a.RelatedIsin, cleanIsin, StringComparison.OrdinalIgnoreCase));
analyses.Add(new SectorAnalysisEntry
{
AnalysisId = analysisId,
AnalysisId = $"sec_{Guid.NewGuid():N}",
Timestamp = nowIso,
RelatedIsin = isin,
RelatedIsin = cleanIsin,
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)
var cutoff = DateTime.UtcNow.AddDays(-14);
var updatedAnalyses = analyses.Where(a =>
{
updatedAnalyses = updatedAnalyses.Take(100).ToList();
}
if (DateTime.TryParse(a.Timestamp, out var ts))
{
return ts >= cutoff;
}
return true;
}).OrderByDescending(a => a.Timestamp).Take(100).ToList();
var activeIsins = updatedAnalyses.Select(a => a.RelatedIsin).Where(i => !string.IsNullOrWhiteSpace(i)).Distinct().ToList();
double avgCompound = updatedAnalyses.Average(a => a.FinbertResult.CompoundScore);
string label = CalculateLabel(avgCompound);
var activeIsins = updatedAnalyses.Select(a => a.RelatedIsin).Where(i => !string.IsNullOrEmpty(i)).Distinct().ToList();
double totalSectorCompound = updatedAnalyses.Sum(s => s.FinbertResult.CompoundScore);
double avgSectorCompound = updatedAnalyses.Count > 0 ? totalSectorCompound / updatedAnalyses.Count : 0.0;
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."
};
string sectorLabel = "NEUTRAL";
if (avgSectorCompound >= 0.15) sectorLabel = "POSITIVE";
else if (avgSectorCompound <= -0.15) sectorLabel = "NEGATIVE";
var updatedDoc = sectorDoc with
var summary = new SectorSentimentSummaryDto
{
Sector = sector,
LastUpdated = nowIso,
CurrentSummary = new SectorCurrentSummary
{
CompoundScore = Math.Round(avgCompound, 2),
SentimentLabel = label,
CompoundScore = Math.Round(avgSectorCompound, 4),
SentimentLabel = sectorLabel,
ActiveIsins = activeIsins,
Text = overviewText
Text = $"Sector {sector} aggregate sentiment: {sectorLabel}."
},
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);
var outJson = JsonSerializer.Serialize(summary, _jsonOptions);
await File.WriteAllTextAsync(filePath, outJson);
await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentStorageService] Updated Sector summary file: {Path} (Active ISINs: {Count})", filePath, activeIsins.Count);
}
catch (Exception ex)
{
await _finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[SentimentStorageService] Failed to update Sector summary for {Sector}", sector);
}
finally
{
fileLock.Release();
}
}
private static string CalculateLabel(double score) => score switch
{
>= 0.15 => "POSITIVE",
<= -0.15 => "NEGATIVE",
_ => "NEUTRAL"
};
}