feat(Sentiment): update sentiment service
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user