Files
Finlytic/FinlyticSentiment/Services/FinBertAnalyzerService.cs
T

205 lines
9.0 KiB
C#

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;
}
}