205 lines
9.0 KiB
C#
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;
|
|
}
|
|
}
|