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; /// /// Defines the sentiment analysis contract for evaluating news articles via FinBERT webhooks. /// public interface IFinBertAnalyzerService { /// /// Analyzes a news article using language-targeted FinBERT webhooks and returns structured metrics. /// /// The news article DTO to evaluate. /// A task returning the FinBERT sentiment analysis result. Task AnalyzeArticleAsync(NewsArticleDto article); } /// /// Implementation of the sentiment analysis contract for evaluating news articles via FinBERT webhooks. /// public class FinBertAnalyzerService : IFinBertAnalyzerService { private readonly HttpClient _httpClient; private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// The HTTP client instance. /// The service scope factory for DB access. /// The logging channel. public FinBertAnalyzerService( HttpClient httpClient, IServiceScopeFactory scopeFactory, ILogger logger) { _httpClient = httpClient; _scopeFactory = scopeFactory; _logger = logger; } /// /// Analyzes a news article using language-targeted FinBERT webhooks and returns structured metrics. /// public async Task AnalyzeArticleAsync(NewsArticleDto article) { ArgumentNullException.ThrowIfNull(article); string targetUrl; double minConfidence; using (var scope = _scopeFactory.CreateScope()) { var settingsDb = scope.ServiceProvider.GetRequiredService(); 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; } }