feat(sentiment): add persistent sentiment entities, summaries, SentimentDbService and MQTT RPC refactoring
This commit is contained in:
@@ -7,7 +7,7 @@ using Microsoft.EntityFrameworkCore.Design;
|
|||||||
namespace FinlyticSentiment.Database;
|
namespace FinlyticSentiment.Database;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// EF Core DbContext for managing FinlyticSentiment settings in PostgreSQL.
|
/// EF Core DbContext for managing FinlyticSentiment persistent entities and dynamic settings.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SentimentDbContext : DbContext, ISettingsDbContext
|
public class SentimentDbContext : DbContext, ISettingsDbContext
|
||||||
{
|
{
|
||||||
@@ -16,24 +16,50 @@ public class SentimentDbContext : DbContext, ISettingsDbContext
|
|||||||
}
|
}
|
||||||
|
|
||||||
public DbSet<SettingEntity> DynamicSettings => Set<SettingEntity>();
|
public DbSet<SettingEntity> DynamicSettings => Set<SettingEntity>();
|
||||||
public DbSet<SentimentSettingsEntity> Settings => Set<SentimentSettingsEntity>();
|
public DbSet<ArticleSentimentEntity> ArticleSentiments => Set<ArticleSentimentEntity>();
|
||||||
|
public DbSet<CompanySentimentSummaryEntity> CompanySentiments => Set<CompanySentimentSummaryEntity>();
|
||||||
|
public DbSet<SectorSentimentSummaryEntity> SectorSentiments => Set<SectorSentimentSummaryEntity>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
base.OnModelCreating(modelBuilder);
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
|
// Dynamic Settings
|
||||||
modelBuilder.Entity<SettingEntity>(entity =>
|
modelBuilder.Entity<SettingEntity>(entity =>
|
||||||
{
|
{
|
||||||
entity.HasKey(e => e.Id);
|
entity.HasKey(e => e.Id);
|
||||||
entity.HasIndex(e => e.Key).IsUnique();
|
entity.HasIndex(e => e.Key).IsUnique();
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<SentimentSettingsEntity>(entity =>
|
// Individual Article Sentiments
|
||||||
|
modelBuilder.Entity<ArticleSentimentEntity>(entity =>
|
||||||
{
|
{
|
||||||
entity.ToTable("sentiment_settings");
|
|
||||||
entity.HasKey(e => e.Id);
|
entity.HasKey(e => e.Id);
|
||||||
entity.Property(e => e.GermanWebhookUrl).HasMaxLength(500);
|
|
||||||
entity.Property(e => e.EnglishWebhookUrl).HasMaxLength(500);
|
// Prevent duplicate analysis of the same article for the same asset
|
||||||
|
entity.HasIndex(e => new { e.ArticleId, e.Isin }).IsUnique();
|
||||||
|
|
||||||
|
// High-performance time-series index for timeline charts & time-decay scans
|
||||||
|
entity.HasIndex(e => new { e.Isin, e.PublishedAtUtc });
|
||||||
|
|
||||||
|
entity.HasIndex(e => e.AnalyzedAtUtc);
|
||||||
|
entity.HasIndex(e => e.Sector);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pre-Aggregated Company Sentiment Summaries
|
||||||
|
modelBuilder.Entity<CompanySentimentSummaryEntity>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(e => e.Isin);
|
||||||
|
entity.HasIndex(e => e.Sector);
|
||||||
|
entity.HasIndex(e => e.WeightedScore);
|
||||||
|
entity.HasIndex(e => e.LastUpdatedUtc);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pre-Aggregated Sector Sentiment Summaries
|
||||||
|
modelBuilder.Entity<SectorSentimentSummaryEntity>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(e => e.Sector);
|
||||||
|
entity.HasIndex(e => e.LastUpdatedUtc);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base
|
FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base
|
||||||
USER app
|
USER $APP_UID
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using System;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace FinlyticSentiment.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Entity representing a single sentiment evaluation of an asset mentioned in a news article.
|
||||||
|
/// </summary>
|
||||||
|
[Table("article_sentiments")]
|
||||||
|
public class ArticleSentimentEntity
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public Guid ArticleId { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(12)]
|
||||||
|
public string Isin { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(256)]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string? Sector { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(32)]
|
||||||
|
public string Label { get; set; } = "NEUTRAL";
|
||||||
|
|
||||||
|
public double CompoundScore { get; set; }
|
||||||
|
|
||||||
|
public double Confidence { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(32)]
|
||||||
|
public string? Impact { get; set; }
|
||||||
|
|
||||||
|
public double PositiveProbability { get; set; }
|
||||||
|
|
||||||
|
public double NegativeProbability { get; set; }
|
||||||
|
|
||||||
|
public double NeutralProbability { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(1024)]
|
||||||
|
public string? KeyHighlight { get; set; }
|
||||||
|
|
||||||
|
public DateTime PublishedAtUtc { get; set; }
|
||||||
|
|
||||||
|
public DateTime AnalyzedAtUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using System;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace FinlyticSentiment.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pre-aggregated, real-time sentiment summary for a company/asset, enabling sub-millisecond lookups without recalculation.
|
||||||
|
/// </summary>
|
||||||
|
[Table("company_sentiment_summaries")]
|
||||||
|
public class CompanySentimentSummaryEntity
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
[MaxLength(12)]
|
||||||
|
public string Isin { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(256)]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string? Sector { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(32)]
|
||||||
|
public string CurrentLabel { get; set; } = "NEUTRAL";
|
||||||
|
|
||||||
|
public double AverageScore { get; set; }
|
||||||
|
|
||||||
|
public double WeightedScore { get; set; }
|
||||||
|
|
||||||
|
public double AverageConfidence { get; set; }
|
||||||
|
|
||||||
|
public int TotalAnalysesCount { get; set; }
|
||||||
|
|
||||||
|
public int PositiveCount { get; set; }
|
||||||
|
|
||||||
|
public int NegativeCount { get; set; }
|
||||||
|
|
||||||
|
public int NeutralCount { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(1024)]
|
||||||
|
public string? LatestKeyHighlight { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(32)]
|
||||||
|
public string? Trend { get; set; } = "STABLE";
|
||||||
|
|
||||||
|
public DateTime LastUpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
[ConcurrencyCheck]
|
||||||
|
public uint Version { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using System;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace FinlyticSentiment.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pre-aggregated real-time sentiment summary for a market sector (e.g. Technology, Healthcare).
|
||||||
|
/// </summary>
|
||||||
|
[Table("sector_sentiment_summaries")]
|
||||||
|
public class SectorSentimentSummaryEntity
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
[MaxLength(128)]
|
||||||
|
public string Sector { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(32)]
|
||||||
|
public string CurrentLabel { get; set; } = "NEUTRAL";
|
||||||
|
|
||||||
|
public double AverageScore { get; set; }
|
||||||
|
|
||||||
|
public int TotalArticlesCount { get; set; }
|
||||||
|
|
||||||
|
public int TotalCompaniesCount { get; set; }
|
||||||
|
|
||||||
|
public DateTime LastUpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace FinlyticSentiment.Entities;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Entity representing runtime operational settings for FinlyticSentiment stored in PostgreSQL.
|
|
||||||
/// </summary>
|
|
||||||
public class SentimentSettingsEntity
|
|
||||||
{
|
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
|
||||||
|
|
||||||
public double MinConfidenceScore { get; set; } = 0.70;
|
|
||||||
|
|
||||||
public int MaxBatchSize { get; set; } = 10;
|
|
||||||
|
|
||||||
public int SweepIntervalMinutes { get; set; } = 2;
|
|
||||||
|
|
||||||
public string GermanWebhookUrl { get; set; } = "https://n8n.kleidukos.me/webhook/sentiment/de";
|
|
||||||
|
|
||||||
public string EnglishWebhookUrl { get; set; } = "https://n8n.kleidukos.me/webhook/sentiment/en";
|
|
||||||
|
|
||||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
-63
@@ -1,63 +0,0 @@
|
|||||||
// <auto-generated />
|
|
||||||
using System;
|
|
||||||
using FinlyticSentiment.Database;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace FinlyticSentiment.Migrations
|
|
||||||
{
|
|
||||||
[DbContext(typeof(SentimentDbContext))]
|
|
||||||
[Migration("20260801090401_InitialSentimentSettings")]
|
|
||||||
partial class InitialSentimentSettings
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
#pragma warning disable 612, 618
|
|
||||||
modelBuilder
|
|
||||||
.HasAnnotation("ProductVersion", "10.0.9")
|
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
|
||||||
|
|
||||||
modelBuilder.Entity("FinlyticSentiment.Entities.SentimentSettingsEntity", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<string>("EnglishWebhookUrl")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(500)
|
|
||||||
.HasColumnType("character varying(500)");
|
|
||||||
|
|
||||||
b.Property<string>("GermanWebhookUrl")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(500)
|
|
||||||
.HasColumnType("character varying(500)");
|
|
||||||
|
|
||||||
b.Property<int>("MaxBatchSize")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<double>("MinConfidenceScore")
|
|
||||||
.HasColumnType("double precision");
|
|
||||||
|
|
||||||
b.Property<int>("SweepIntervalMinutes")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<DateTime>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.ToTable("sentiment_settings", (string)null);
|
|
||||||
});
|
|
||||||
#pragma warning restore 612, 618
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace FinlyticSentiment.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class InitialSentimentSettings : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "sentiment_settings",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
|
||||||
MinConfidenceScore = table.Column<double>(type: "double precision", nullable: false),
|
|
||||||
MaxBatchSize = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
SweepIntervalMinutes = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
GermanWebhookUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
|
||||||
EnglishWebhookUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
|
||||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_sentiment_settings", x => x.Id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "sentiment_settings");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-63
@@ -1,63 +0,0 @@
|
|||||||
// <auto-generated />
|
|
||||||
using System;
|
|
||||||
using FinlyticSentiment.Database;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace FinlyticSentiment.Migrations
|
|
||||||
{
|
|
||||||
[DbContext(typeof(SentimentDbContext))]
|
|
||||||
[Migration("20260813202634_CheckPendingSentiment")]
|
|
||||||
partial class CheckPendingSentiment
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
#pragma warning disable 612, 618
|
|
||||||
modelBuilder
|
|
||||||
.HasAnnotation("ProductVersion", "10.0.9")
|
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
|
||||||
|
|
||||||
modelBuilder.Entity("FinlyticSentiment.Entities.SentimentSettingsEntity", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<string>("EnglishWebhookUrl")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(500)
|
|
||||||
.HasColumnType("character varying(500)");
|
|
||||||
|
|
||||||
b.Property<string>("GermanWebhookUrl")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(500)
|
|
||||||
.HasColumnType("character varying(500)");
|
|
||||||
|
|
||||||
b.Property<int>("MaxBatchSize")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<double>("MinConfidenceScore")
|
|
||||||
.HasColumnType("double precision");
|
|
||||||
|
|
||||||
b.Property<int>("SweepIntervalMinutes")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<DateTime>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.ToTable("sentiment_settings", (string)null);
|
|
||||||
});
|
|
||||||
#pragma warning restore 612, 618
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace FinlyticSentiment.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class CheckPendingSentiment : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
// <auto-generated />
|
|
||||||
using System;
|
|
||||||
using FinlyticSentiment.Database;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace FinlyticSentiment.Migrations
|
|
||||||
{
|
|
||||||
[DbContext(typeof(SentimentDbContext))]
|
|
||||||
[Migration("20260815184006_AddDynamicSettings")]
|
|
||||||
partial class AddDynamicSettings
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
#pragma warning disable 612, 618
|
|
||||||
modelBuilder
|
|
||||||
.HasAnnotation("ProductVersion", "10.0.9")
|
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
|
||||||
|
|
||||||
modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<string>("Key")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(150)
|
|
||||||
.HasColumnType("character varying(150)");
|
|
||||||
|
|
||||||
b.Property<DateTime>("LastUpdatedUtc")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.Property<string>("ServiceIdentifier")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)");
|
|
||||||
|
|
||||||
b.Property<string>("ValueJson")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("Key")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("DynamicSettings");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("FinlyticSentiment.Entities.SentimentSettingsEntity", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<string>("EnglishWebhookUrl")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(500)
|
|
||||||
.HasColumnType("character varying(500)");
|
|
||||||
|
|
||||||
b.Property<string>("GermanWebhookUrl")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(500)
|
|
||||||
.HasColumnType("character varying(500)");
|
|
||||||
|
|
||||||
b.Property<int>("MaxBatchSize")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<double>("MinConfidenceScore")
|
|
||||||
.HasColumnType("double precision");
|
|
||||||
|
|
||||||
b.Property<int>("SweepIntervalMinutes")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<DateTime>("UpdatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.ToTable("sentiment_settings", (string)null);
|
|
||||||
});
|
|
||||||
#pragma warning restore 612, 618
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace FinlyticSentiment.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddDynamicSettings : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "DynamicSettings",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
|
||||||
Key = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: false),
|
|
||||||
ValueJson = table.Column<string>(type: "text", nullable: false),
|
|
||||||
ServiceIdentifier = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
|
||||||
LastUpdatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_DynamicSettings", x => x.Id);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_DynamicSettings_Key",
|
|
||||||
table: "DynamicSettings",
|
|
||||||
column: "Key",
|
|
||||||
unique: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "DynamicSettings");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using FinlyticSentiment.Database;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace FinlyticSentiment.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(SentimentDbContext))]
|
||||||
|
[Migration("20260818202351_Init")]
|
||||||
|
partial class Init
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.9")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("character varying(150)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastUpdatedUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ServiceIdentifier")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("ValueJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Key")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("DynamicSettings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticSentiment.Entities.ArticleSentimentEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("AnalyzedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("ArticleId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<double>("CompoundScore")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b.Property<double>("Confidence")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b.Property<string>("Impact")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<string>("Isin")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(12)
|
||||||
|
.HasColumnType("character varying(12)");
|
||||||
|
|
||||||
|
b.Property<string>("KeyHighlight")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)");
|
||||||
|
|
||||||
|
b.Property<string>("Label")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<double>("NegativeProbability")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b.Property<double>("NeutralProbability")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b.Property<double>("PositiveProbability")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b.Property<DateTime>("PublishedAtUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Sector")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AnalyzedAtUtc");
|
||||||
|
|
||||||
|
b.HasIndex("Sector");
|
||||||
|
|
||||||
|
b.HasIndex("ArticleId", "Isin")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("Isin", "PublishedAtUtc");
|
||||||
|
|
||||||
|
b.ToTable("article_sentiments");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticSentiment.Entities.CompanySentimentSummaryEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Isin")
|
||||||
|
.HasMaxLength(12)
|
||||||
|
.HasColumnType("character varying(12)");
|
||||||
|
|
||||||
|
b.Property<double>("AverageConfidence")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b.Property<double>("AverageScore")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b.Property<string>("CurrentLabel")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastUpdatedUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("LatestKeyHighlight")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<int>("NegativeCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("NeutralCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("PositiveCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Sector")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<int>("TotalAnalysesCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Trend")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<long>("Version")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<double>("WeightedScore")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b.HasKey("Isin");
|
||||||
|
|
||||||
|
b.HasIndex("LastUpdatedUtc");
|
||||||
|
|
||||||
|
b.HasIndex("Sector");
|
||||||
|
|
||||||
|
b.HasIndex("WeightedScore");
|
||||||
|
|
||||||
|
b.ToTable("company_sentiment_summaries");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticSentiment.Entities.SectorSentimentSummaryEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Sector")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<double>("AverageScore")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b.Property<string>("CurrentLabel")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastUpdatedUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("TotalArticlesCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("TotalCompaniesCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Sector");
|
||||||
|
|
||||||
|
b.HasIndex("LastUpdatedUtc");
|
||||||
|
|
||||||
|
b.ToTable("sector_sentiment_summaries");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace FinlyticSentiment.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Init : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "article_sentiments",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
ArticleId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Isin = table.Column<string>(type: "character varying(12)", maxLength: 12, nullable: false),
|
||||||
|
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||||
|
Sector = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||||
|
Label = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
|
CompoundScore = table.Column<double>(type: "double precision", nullable: false),
|
||||||
|
Confidence = table.Column<double>(type: "double precision", nullable: false),
|
||||||
|
Impact = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
|
||||||
|
PositiveProbability = table.Column<double>(type: "double precision", nullable: false),
|
||||||
|
NegativeProbability = table.Column<double>(type: "double precision", nullable: false),
|
||||||
|
NeutralProbability = table.Column<double>(type: "double precision", nullable: false),
|
||||||
|
KeyHighlight = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||||
|
PublishedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
AnalyzedAtUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_article_sentiments", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "company_sentiment_summaries",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Isin = table.Column<string>(type: "character varying(12)", maxLength: 12, nullable: false),
|
||||||
|
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||||
|
Sector = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||||
|
CurrentLabel = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
|
AverageScore = table.Column<double>(type: "double precision", nullable: false),
|
||||||
|
WeightedScore = table.Column<double>(type: "double precision", nullable: false),
|
||||||
|
AverageConfidence = table.Column<double>(type: "double precision", nullable: false),
|
||||||
|
TotalAnalysesCount = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
PositiveCount = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
NegativeCount = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
NeutralCount = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
LatestKeyHighlight = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||||
|
Trend = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
|
||||||
|
LastUpdatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
Version = table.Column<long>(type: "bigint", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_company_sentiment_summaries", x => x.Isin);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "DynamicSettings",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Key = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: false),
|
||||||
|
ValueJson = table.Column<string>(type: "text", nullable: false),
|
||||||
|
ServiceIdentifier = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
LastUpdatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_DynamicSettings", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "sector_sentiment_summaries",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Sector = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||||
|
CurrentLabel = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
|
AverageScore = table.Column<double>(type: "double precision", nullable: false),
|
||||||
|
TotalArticlesCount = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
TotalCompaniesCount = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
LastUpdatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_sector_sentiment_summaries", x => x.Sector);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_article_sentiments_AnalyzedAtUtc",
|
||||||
|
table: "article_sentiments",
|
||||||
|
column: "AnalyzedAtUtc");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_article_sentiments_ArticleId_Isin",
|
||||||
|
table: "article_sentiments",
|
||||||
|
columns: new[] { "ArticleId", "Isin" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_article_sentiments_Isin_PublishedAtUtc",
|
||||||
|
table: "article_sentiments",
|
||||||
|
columns: new[] { "Isin", "PublishedAtUtc" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_article_sentiments_Sector",
|
||||||
|
table: "article_sentiments",
|
||||||
|
column: "Sector");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_company_sentiment_summaries_LastUpdatedUtc",
|
||||||
|
table: "company_sentiment_summaries",
|
||||||
|
column: "LastUpdatedUtc");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_company_sentiment_summaries_Sector",
|
||||||
|
table: "company_sentiment_summaries",
|
||||||
|
column: "Sector");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_company_sentiment_summaries_WeightedScore",
|
||||||
|
table: "company_sentiment_summaries",
|
||||||
|
column: "WeightedScore");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_DynamicSettings_Key",
|
||||||
|
table: "DynamicSettings",
|
||||||
|
column: "Key",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_sector_sentiment_summaries_LastUpdatedUtc",
|
||||||
|
table: "sector_sentiment_summaries",
|
||||||
|
column: "LastUpdatedUtc");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "article_sentiments");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "company_sentiment_summaries");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "DynamicSettings");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "sector_sentiment_summaries");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,37 +53,172 @@ namespace FinlyticSentiment.Migrations
|
|||||||
b.ToTable("DynamicSettings");
|
b.ToTable("DynamicSettings");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("FinlyticSentiment.Entities.SentimentSettingsEntity", b =>
|
modelBuilder.Entity("FinlyticSentiment.Entities.ArticleSentimentEntity", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<string>("EnglishWebhookUrl")
|
b.Property<DateTime>("AnalyzedAtUtc")
|
||||||
.IsRequired()
|
.HasColumnType("timestamp with time zone");
|
||||||
.HasMaxLength(500)
|
|
||||||
.HasColumnType("character varying(500)");
|
|
||||||
|
|
||||||
b.Property<string>("GermanWebhookUrl")
|
b.Property<Guid>("ArticleId")
|
||||||
.IsRequired()
|
.HasColumnType("uuid");
|
||||||
.HasMaxLength(500)
|
|
||||||
.HasColumnType("character varying(500)");
|
|
||||||
|
|
||||||
b.Property<int>("MaxBatchSize")
|
b.Property<double>("CompoundScore")
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<double>("MinConfidenceScore")
|
|
||||||
.HasColumnType("double precision");
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
b.Property<int>("SweepIntervalMinutes")
|
b.Property<double>("Confidence")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
b.Property<DateTime>("UpdatedAt")
|
b.Property<string>("Impact")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<string>("Isin")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(12)
|
||||||
|
.HasColumnType("character varying(12)");
|
||||||
|
|
||||||
|
b.Property<string>("KeyHighlight")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)");
|
||||||
|
|
||||||
|
b.Property<string>("Label")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<double>("NegativeProbability")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b.Property<double>("NeutralProbability")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b.Property<double>("PositiveProbability")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b.Property<DateTime>("PublishedAtUtc")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Sector")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.ToTable("sentiment_settings", (string)null);
|
b.HasIndex("AnalyzedAtUtc");
|
||||||
|
|
||||||
|
b.HasIndex("Sector");
|
||||||
|
|
||||||
|
b.HasIndex("ArticleId", "Isin")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("Isin", "PublishedAtUtc");
|
||||||
|
|
||||||
|
b.ToTable("article_sentiments");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticSentiment.Entities.CompanySentimentSummaryEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Isin")
|
||||||
|
.HasMaxLength(12)
|
||||||
|
.HasColumnType("character varying(12)");
|
||||||
|
|
||||||
|
b.Property<double>("AverageConfidence")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b.Property<double>("AverageScore")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b.Property<string>("CurrentLabel")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastUpdatedUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("LatestKeyHighlight")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<int>("NegativeCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("NeutralCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("PositiveCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Sector")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<int>("TotalAnalysesCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Trend")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<long>("Version")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<double>("WeightedScore")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b.HasKey("Isin");
|
||||||
|
|
||||||
|
b.HasIndex("LastUpdatedUtc");
|
||||||
|
|
||||||
|
b.HasIndex("Sector");
|
||||||
|
|
||||||
|
b.HasIndex("WeightedScore");
|
||||||
|
|
||||||
|
b.ToTable("company_sentiment_summaries");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticSentiment.Entities.SectorSentimentSummaryEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Sector")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)");
|
||||||
|
|
||||||
|
b.Property<double>("AverageScore")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b.Property<string>("CurrentLabel")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastUpdatedUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("TotalArticlesCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("TotalCompaniesCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Sector");
|
||||||
|
|
||||||
|
b.HasIndex("LastUpdatedUtc");
|
||||||
|
|
||||||
|
b.ToTable("sector_sentiment_summaries");
|
||||||
});
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ using Microsoft.Extensions.Hosting;
|
|||||||
|
|
||||||
var builder = Host.CreateApplicationBuilder(args);
|
var builder = Host.CreateApplicationBuilder(args);
|
||||||
|
|
||||||
// Register PostgreSQL DbContext for settings persistence
|
// Register PostgreSQL DbContext for sentiment data & settings
|
||||||
builder.Services.AddDbContext<SentimentDbContext>(options =>
|
builder.Services.AddDbContext<SentimentDbContext>(options =>
|
||||||
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||||
builder.Services.AddScoped<ISettingsDbContext>(sp => sp.GetRequiredService<SentimentDbContext>());
|
builder.Services.AddScoped<ISettingsDbContext>(sp => sp.GetRequiredService<SentimentDbContext>());
|
||||||
@@ -23,10 +23,9 @@ builder.Services.AddSingleton(typeof(IFinlyticLogger<>), typeof(FinlyticLogger<>
|
|||||||
// Register HttpClient
|
// Register HttpClient
|
||||||
builder.Services.AddHttpClient();
|
builder.Services.AddHttpClient();
|
||||||
|
|
||||||
// Register Service interfaces and co-located implementations
|
// Register Sentiment Services
|
||||||
builder.Services.AddScoped<ISettingsDbService, SettingsDbService>();
|
builder.Services.AddScoped<ISentimentDbService, SentimentDbService>();
|
||||||
builder.Services.AddSingleton<IFinBertAnalyzerService, FinBertAnalyzerService>();
|
builder.Services.AddSingleton<IFinBertAnalyzerService, FinBertAnalyzerService>();
|
||||||
builder.Services.AddSingleton<ISentimentStorageService, SentimentStorageService>();
|
|
||||||
|
|
||||||
// Register MQTT Client (as singleton hosted service)
|
// Register MQTT Client (as singleton hosted service)
|
||||||
builder.Services.AddSingleton<SentimentMqttClient>();
|
builder.Services.AddSingleton<SentimentMqttClient>();
|
||||||
@@ -43,12 +42,14 @@ using (var scope = host.Services.CreateScope())
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var db = scope.ServiceProvider.GetRequiredService<SentimentDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<SentimentDbContext>();
|
||||||
await db.Database.MigrateAsync();
|
var connStr = builder.Configuration.GetConnectionString("DefaultConnection") ?? "";
|
||||||
|
await db.MigrateWithBootstrapAsync(connStr);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Critical error during database migration for FinlyticSentiment: {ex.Message}");
|
Console.WriteLine($"Critical error during database migration for FinlyticSentiment: {ex.Message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await host.RunAsync();
|
await host.RunAsync();
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
# Finlytic Sentiment Service
|
|
||||||
|
|
||||||
Finlytic Sentiment is a C# microservice dedicated to real-time AI sentiment analysis of financial news. It consumes pending news articles, runs FinBERT neural model evaluations, and aggregates sentiment scores at the asset (ISIN) and sector level.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Core Features & Architecture
|
|
||||||
|
|
||||||
1. **FinBERT AI Integration**:
|
|
||||||
- Evaluates positive, negative, and neutral sentiment probabilities (`positiveProbability`, `negativeProbability`, `neutralProbability`, `compoundScore`).
|
|
||||||
|
|
||||||
2. **ISIN & Sector Sentiment Aggregation**:
|
|
||||||
- Aggregates sentiment scores per financial asset (`IsinSentimentSummaryDto`) and sector (`SectorSentimentSummaryDto`).
|
|
||||||
|
|
||||||
3. **Background Worker Engine**:
|
|
||||||
- Runs a 5-minute background loop (`SentimentBackgroundService`) polling pending articles, processing FinBERT evaluations, and publishing results over MQTT.
|
|
||||||
|
|
||||||
4. **MQTT Event Channels**:
|
|
||||||
- Publishes updates to `finlytic/sentiment/result` and responds to RPC requests on `services/request/sentiment_GetArticle/#` and `services/request/sentiment_GetIsin/#`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Feature Status
|
|
||||||
|
|
||||||
### Implemented Features
|
|
||||||
- [x] FinBERT AI Sentiment Evaluation Service (`IFinBertAnalyzerService`).
|
|
||||||
- [x] Sentiment Storage & In-Memory Aggregation (`ISentimentStorageService`).
|
|
||||||
- [x] 5-minute Background Worker loop (`SentimentBackgroundService`).
|
|
||||||
- [x] Zero-Allocation MQTT serialization via `FinlyticJsonSerializerContext`.
|
|
||||||
- [x] Pure Worker Service architecture (`Host.CreateApplicationBuilder`, no Kestrel HTTP server).
|
|
||||||
|
|
||||||
### Planned Features
|
|
||||||
- [ ] Historical sentiment trend charting (multi-month sentiment drift per asset).
|
|
||||||
- [ ] Financial entity sentiment impact correlation model.
|
|
||||||
@@ -52,28 +52,15 @@ public class FinBertAnalyzerService : IFinBertAnalyzerService
|
|||||||
double minConfidence = 0.60;
|
double minConfidence = 0.60;
|
||||||
using (var scope = _scopeFactory.CreateScope())
|
using (var scope = _scopeFactory.CreateScope())
|
||||||
{
|
{
|
||||||
var settingsService = scope.ServiceProvider.GetService<ISettingsService>();
|
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
|
||||||
bool isEnglish = string.Equals(article.Language, "en", StringComparison.OrdinalIgnoreCase);
|
bool isEnglish = string.Equals(article.Language, "en", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
if (settingsService != null)
|
|
||||||
{
|
|
||||||
targetUrl = isEnglish
|
targetUrl = isEnglish
|
||||||
? await settingsService.GetSettingAsync(SettingKeys.EnglishWebhookUrl)
|
? await settingsService.GetSettingAsync(SettingKeys.EnglishWebhookUrl)
|
||||||
: await settingsService.GetSettingAsync(SettingKeys.GermanWebhookUrl);
|
: await settingsService.GetSettingAsync(SettingKeys.GermanWebhookUrl);
|
||||||
minConfidence = await settingsService.GetSettingAsync(SettingKeys.MinimumConfidenceThreshold);
|
minConfidence = await settingsService.GetSettingAsync(SettingKeys.MinimumConfidenceThreshold);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(targetUrl))
|
|
||||||
{
|
|
||||||
var settingsDb = scope.ServiceProvider.GetRequiredService<ISettingsDbService>();
|
|
||||||
var settings = await settingsDb.GetSettingsAsync();
|
|
||||||
targetUrl = isEnglish
|
|
||||||
? settings.EnglishWebhookUrl
|
|
||||||
: settings.GermanWebhookUrl;
|
|
||||||
minConfidence = settings.MinConfidenceScore;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[FinBertAnalyzerService] Analyzing article (ID: {Id}, Lang: {Lang}) via webhook: {Url} (MinConf: {Conf})", article.Id, article.Language ?? "de", targetUrl, minConfidence);
|
await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[FinBertAnalyzerService] Analyzing article (ID: {Id}, Lang: {Lang}) via webhook: {Url} (MinConf: {Conf})", article.Id, article.Language ?? "de", targetUrl, minConfidence);
|
||||||
|
|
||||||
var requestBody = new
|
var requestBody = new
|
||||||
@@ -128,11 +115,11 @@ public class FinBertAnalyzerService : IFinBertAnalyzerService
|
|||||||
double confidence = GetDoubleProp(root, "confidence")
|
double confidence = GetDoubleProp(root, "confidence")
|
||||||
?? GetDoubleProp(root, "confidence_score")
|
?? GetDoubleProp(root, "confidence_score")
|
||||||
?? 0.5;
|
?? 0.5;
|
||||||
string snippet = GetStringProp(root, "summary_snippet")
|
string? impact = GetStringProp(root, "impact") ?? "MEDIUM";
|
||||||
|
string? keyHighlight = GetStringProp(root, "key_highlight")
|
||||||
|
?? GetStringProp(root, "summary_snippet")
|
||||||
?? GetStringProp(root, "summary")
|
?? GetStringProp(root, "summary")
|
||||||
?? GetStringProp(root, "text")
|
?? article.Summary;
|
||||||
?? article.Summary
|
|
||||||
?? article.Title;
|
|
||||||
|
|
||||||
double pos = 0.0, neg = 0.0, neu = 1.0;
|
double pos = 0.0, neg = 0.0, neu = 1.0;
|
||||||
if (root.TryGetProperty("probabilities", out var probsElem) && probsElem.ValueKind == JsonValueKind.Object)
|
if (root.TryGetProperty("probabilities", out var probsElem) && probsElem.ValueKind == JsonValueKind.Object)
|
||||||
@@ -165,44 +152,57 @@ public class FinBertAnalyzerService : IFinBertAnalyzerService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new FinBertResultDto
|
var result = new FinBertResultDto
|
||||||
{
|
{
|
||||||
Label = label,
|
Label = label,
|
||||||
CompoundScore = Math.Round(compoundScore, 4),
|
CompoundScore = Math.Round(compoundScore, 4),
|
||||||
Confidence = Math.Round(confidence, 4),
|
Confidence = Math.Round(confidence, 4),
|
||||||
|
Impact = impact.ToUpperInvariant(),
|
||||||
|
KeyHighlight = keyHighlight,
|
||||||
Probabilities = new FinBertProbabilities
|
Probabilities = new FinBertProbabilities
|
||||||
{
|
{
|
||||||
Positive = Math.Round(pos, 4),
|
Positive = Math.Round(pos, 4),
|
||||||
Negative = Math.Round(neg, 4),
|
Negative = Math.Round(neg, 4),
|
||||||
Neutral = Math.Round(neu, 4)
|
Neutral = Math.Round(neu, 4)
|
||||||
},
|
}
|
||||||
SummarySnippet = snippet
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await _finlyticLogger.LogWarningAsync(SettingKeys.SentimentChannel, "[FinBertAnalyzerService] n8n Webhook returned non-success status: {StatusCode}.", response.StatusCode);
|
await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[FinBertAnalyzerService] Successfully analyzed article {Id}: {Label} (Compound: {Score}, Conf: {Conf}, Impact: {Impact})", article.Id, result.Label, result.CompoundScore, result.Confidence, result.Impact);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await _finlyticLogger.LogWarningAsync(SettingKeys.SentimentChannel, "[FinBertAnalyzerService] Webhook call failed with status: {Status} (Article ID: {Id})", response.StatusCode, article.Id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await _finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[FinBertAnalyzerService] Failed to call n8n sentiment webhook.");
|
await _finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[FinBertAnalyzerService] Exception during webhook request for article: {Id}", article.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? GetStringProp(JsonElement elem, string propName)
|
private static string? GetStringProp(JsonElement element, string propName)
|
||||||
{
|
{
|
||||||
return elem.TryGetProperty(propName, out var prop) && prop.ValueKind == JsonValueKind.String ? prop.GetString() : null;
|
if (element.TryGetProperty(propName, out var prop) && prop.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
return prop.GetString();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static double? GetDoubleProp(JsonElement elem, string propName)
|
private static double? GetDoubleProp(JsonElement element, string propName)
|
||||||
{
|
{
|
||||||
if (elem.TryGetProperty(propName, out var prop))
|
if (element.TryGetProperty(propName, out var prop))
|
||||||
{
|
{
|
||||||
if (prop.ValueKind == JsonValueKind.Number && prop.TryGetDouble(out var d)) return d;
|
if (prop.ValueKind == JsonValueKind.Number && prop.TryGetDouble(out var d))
|
||||||
if (prop.ValueKind == JsonValueKind.String && double.TryParse(prop.GetString(), out var parsed)) return parsed;
|
return d;
|
||||||
|
if (prop.ValueKind == JsonValueKind.String && double.TryParse(prop.GetString(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var parsed))
|
||||||
|
return parsed;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,13 +13,12 @@ namespace FinlyticSentiment.Services;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Background hosted worker executing periodic sentiment analysis sweeps on pending news articles
|
/// Background hosted worker executing periodic sentiment analysis sweeps on pending news articles
|
||||||
/// and processing real-time article broadcasts.
|
/// and processing real-time article broadcasts from FinlyticNews.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SentimentBackgroundService : BackgroundService
|
public class SentimentBackgroundService : BackgroundService
|
||||||
{
|
{
|
||||||
private readonly SentimentMqttClient _mqttClient;
|
private readonly SentimentMqttClient _mqttClient;
|
||||||
private readonly IFinBertAnalyzerService _analyzer;
|
private readonly IFinBertAnalyzerService _analyzer;
|
||||||
private readonly ISentimentStorageService _storage;
|
|
||||||
private readonly IServiceScopeFactory _scopeFactory;
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
private readonly IFinlyticLogger<SentimentBackgroundService> _finlyticLogger;
|
private readonly IFinlyticLogger<SentimentBackgroundService> _finlyticLogger;
|
||||||
|
|
||||||
@@ -28,13 +27,11 @@ public class SentimentBackgroundService : BackgroundService
|
|||||||
public SentimentBackgroundService(
|
public SentimentBackgroundService(
|
||||||
SentimentMqttClient mqttClient,
|
SentimentMqttClient mqttClient,
|
||||||
IFinBertAnalyzerService analyzer,
|
IFinBertAnalyzerService analyzer,
|
||||||
ISentimentStorageService storage,
|
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
IFinlyticLogger<SentimentBackgroundService> finlyticLogger)
|
IFinlyticLogger<SentimentBackgroundService> finlyticLogger)
|
||||||
{
|
{
|
||||||
_mqttClient = mqttClient;
|
_mqttClient = mqttClient;
|
||||||
_analyzer = analyzer;
|
_analyzer = analyzer;
|
||||||
_storage = storage;
|
|
||||||
_scopeFactory = scopeFactory;
|
_scopeFactory = scopeFactory;
|
||||||
_finlyticLogger = finlyticLogger;
|
_finlyticLogger = finlyticLogger;
|
||||||
}
|
}
|
||||||
@@ -56,11 +53,13 @@ public class SentimentBackgroundService : BackgroundService
|
|||||||
int maxBatchSize = 10;
|
int maxBatchSize = 10;
|
||||||
int sweepIntervalMinutes = 5;
|
int sweepIntervalMinutes = 5;
|
||||||
|
|
||||||
using (var scope = _scopeFactory.CreateScope())
|
try
|
||||||
{
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var settings = scope.ServiceProvider.GetRequiredService<ISettingsService>();
|
var settings = scope.ServiceProvider.GetRequiredService<ISettingsService>();
|
||||||
maxBatchSize = await settings.GetSettingAsync(SettingKeys.MaxBatchSize, stoppingToken);
|
maxBatchSize = await settings.GetSettingAsync(SettingKeys.MaxBatchSize, stoppingToken);
|
||||||
}
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
var interval = TimeSpan.FromMinutes(Math.Max(1, sweepIntervalMinutes));
|
var interval = TimeSpan.FromMinutes(Math.Max(1, sweepIntervalMinutes));
|
||||||
|
|
||||||
@@ -79,10 +78,9 @@ public class SentimentBackgroundService : BackgroundService
|
|||||||
|
|
||||||
await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] Waiting {Minutes} minute(s) until next sentiment sweep...", interval.TotalMinutes);
|
await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] Waiting {Minutes} minute(s) until next sentiment sweep...", interval.TotalMinutes);
|
||||||
|
|
||||||
using var timer = new PeriodicTimer(interval);
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await timer.WaitForNextTickAsync(stoppingToken);
|
await Task.Delay(interval, stoppingToken);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -90,7 +88,7 @@ public class SentimentBackgroundService : BackgroundService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] FinlyticSentiment Background Service is shutting down gracefully.");
|
await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] FinlyticSentiment Background Service shutting down.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task PerformSentimentSweepAsync(int maxBatchSize, CancellationToken cancellationToken)
|
private async Task PerformSentimentSweepAsync(int maxBatchSize, CancellationToken cancellationToken)
|
||||||
@@ -137,45 +135,40 @@ public class SentimentBackgroundService : BackgroundService
|
|||||||
|
|
||||||
if (cancellationToken.IsCancellationRequested) return;
|
if (cancellationToken.IsCancellationRequested) return;
|
||||||
|
|
||||||
await _storage.SaveArticleSentimentAsync(article, finbert);
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var dbService = scope.ServiceProvider.GetRequiredService<ISentimentDbService>();
|
||||||
|
|
||||||
if (article.MatchedAssets != null && article.MatchedAssets.Count > 0)
|
if (article.MatchedAssets != null && article.MatchedAssets.Count > 0)
|
||||||
{
|
{
|
||||||
foreach (var asset in article.MatchedAssets)
|
foreach (var asset in article.MatchedAssets)
|
||||||
{
|
{
|
||||||
if (cancellationToken.IsCancellationRequested) break;
|
|
||||||
if (string.IsNullOrWhiteSpace(asset.Isin)) continue;
|
if (string.IsNullOrWhiteSpace(asset.Isin)) continue;
|
||||||
|
|
||||||
await _storage.UpdateIsinSummaryAsync(
|
await dbService.SaveArticleSentimentAsync(
|
||||||
asset.Isin,
|
articleId: article.Id,
|
||||||
asset.Name,
|
isin: asset.Isin,
|
||||||
"General",
|
companyName: asset.Name,
|
||||||
article,
|
sector: null,
|
||||||
finbert);
|
publishedAt: article.PublishedAt,
|
||||||
|
finbert: finbert,
|
||||||
|
ct: cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
await _storage.UpdateSectorSummaryAsync(
|
// Broadcast real-time updated summary for this asset
|
||||||
"General",
|
var summaryDto = await dbService.GetIsinSummaryDtoAsync(asset.Isin, cancellationToken);
|
||||||
asset.Isin,
|
if (summaryDto != null)
|
||||||
article.Id.ToString(),
|
|
||||||
finbert);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cancellationToken.IsCancellationRequested) return;
|
|
||||||
|
|
||||||
bool updated = await _mqttClient.UpdateArticleStatusAsync(article.Id, "Analyzed");
|
|
||||||
if (updated)
|
|
||||||
{
|
{
|
||||||
await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] Article sentiment processed and status set to 'Analyzed' in FinlyticNews: {Title} (ID: {Id}) -> {Label}", article.Title, article.Id, finbert.Label);
|
await _mqttClient.BroadcastSentimentResultAsync(asset.Isin, summaryDto);
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
await _finlyticLogger.LogWarningAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] Failed to confirm status update to 'Analyzed' in FinlyticNews for article: {Id}", article.Id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await _mqttClient.UpdateArticleStatusAsync(article.Id, "Analyzed");
|
||||||
|
await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] Completed sentiment persistence and status transition for article {Id}.", article.Id);
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await _finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[SentimentBackgroundService] Error processing sentiment for article: {Id} ({Title})", article.Id, article.Title);
|
await _finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[SentimentBackgroundService] Failed to process sentiment for article: {Id}", article.Id);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,556 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FinlyticCore.Dtos.News;
|
||||||
|
using FinlyticCore.Dtos.Sentiment;
|
||||||
|
using FinlyticCore.Services;
|
||||||
|
using FinlyticSentiment.Database;
|
||||||
|
using FinlyticSentiment.Entities;
|
||||||
|
using FinlyticSentiment.Util;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace FinlyticSentiment.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service contract for persisting and retrieving asset, article, and sector sentiments.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISentimentDbService
|
||||||
|
{
|
||||||
|
Task<ArticleSentimentEntity?> SaveArticleSentimentAsync(
|
||||||
|
Guid articleId,
|
||||||
|
string isin,
|
||||||
|
string companyName,
|
||||||
|
string? sector,
|
||||||
|
DateTime publishedAt,
|
||||||
|
FinBertResultDto finbert,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task<CompanySentimentSummaryEntity?> GetCompanySentimentAsync(string isin, CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task<IsinSentimentSummaryDto?> GetIsinSummaryDtoAsync(string isin, CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task<List<CompanySentimentSummaryEntity>> GetAllCompanySentimentsAsync(int limit, int offset, string? sector = null, CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task<SectorSentimentSummaryDto?> GetSectorSentimentAsync(string sector, CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task<List<ArticleSentimentEntity>> GetArticleSentimentsAsync(Guid articleId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task<IsinAnalysisEntry?> GetArticleSentimentEntryAsync(Guid articleId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task<List<ArticleSentimentEntity>> GetSentimentTimelineAsync(string isin, int days, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Database persistence service implementing exponential half-life time-decay scoring,
|
||||||
|
/// optimistic concurrency protection, and sub-millisecond pre-aggregated lookups.
|
||||||
|
/// </summary>
|
||||||
|
public class SentimentDbService : ISentimentDbService
|
||||||
|
{
|
||||||
|
private readonly SentimentDbContext _context;
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly IFinlyticLogger<SentimentDbService> _finlyticLogger;
|
||||||
|
|
||||||
|
public SentimentDbService(
|
||||||
|
SentimentDbContext context,
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
IFinlyticLogger<SentimentDbService> finlyticLogger)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_finlyticLogger = finlyticLogger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<ArticleSentimentEntity?> SaveArticleSentimentAsync(
|
||||||
|
Guid articleId,
|
||||||
|
string isin,
|
||||||
|
string companyName,
|
||||||
|
string? sector,
|
||||||
|
DateTime publishedAt,
|
||||||
|
FinBertResultDto finbert,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (articleId == Guid.Empty || string.IsNullOrWhiteSpace(isin) || finbert == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var cleanIsin = isin.Trim().ToUpperInvariant();
|
||||||
|
var cleanName = !string.IsNullOrWhiteSpace(companyName) ? companyName.Trim() : cleanIsin;
|
||||||
|
var cleanSector = !string.IsNullOrWhiteSpace(sector) ? sector.Trim() : null;
|
||||||
|
|
||||||
|
var publishedUtc = publishedAt.Kind == DateTimeKind.Unspecified
|
||||||
|
? DateTime.SpecifyKind(publishedAt, DateTimeKind.Utc)
|
||||||
|
: publishedAt.ToUniversalTime();
|
||||||
|
|
||||||
|
var articleSentiment = new ArticleSentimentEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ArticleId = articleId,
|
||||||
|
Isin = cleanIsin,
|
||||||
|
Name = cleanName,
|
||||||
|
Sector = cleanSector,
|
||||||
|
Label = finbert.Label,
|
||||||
|
CompoundScore = finbert.CompoundScore,
|
||||||
|
Confidence = finbert.Confidence,
|
||||||
|
Impact = finbert.Impact,
|
||||||
|
PositiveProbability = finbert.Probabilities.Positive,
|
||||||
|
NegativeProbability = finbert.Probabilities.Negative,
|
||||||
|
NeutralProbability = finbert.Probabilities.Neutral,
|
||||||
|
KeyHighlight = finbert.KeyHighlight,
|
||||||
|
PublishedAtUtc = publishedUtc,
|
||||||
|
AnalyzedAtUtc = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Insert Article Sentiment with Idempotency Protection
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var existing = await _context.ArticleSentiments
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(a => a.ArticleId == articleId && a.Isin == cleanIsin, ct);
|
||||||
|
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
await _finlyticLogger.LogDebugAsync(SettingKeys.SentimentChannel, "[SentimentDbService] Article {ArticleId} already has sentiment for ISIN {Isin}.", articleId, cleanIsin);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
_context.ArticleSentiments.Add(articleSentiment);
|
||||||
|
await _context.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException ex)
|
||||||
|
{
|
||||||
|
_context.ChangeTracker.Clear();
|
||||||
|
await _finlyticLogger.LogWarningAsync(SettingKeys.SentimentChannel, "[SentimentDbService] Unique constraint or concurrency hit on insert for ISIN {Isin}. Message: {Msg}", cleanIsin, ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Concurrency-Safe Recalculation of Company Summary with Exponential Time-Decay
|
||||||
|
await UpdateCompanySummaryWithRetryAsync(cleanIsin, cleanName, cleanSector, ct);
|
||||||
|
|
||||||
|
// 3. Update Sector Summary if sector is present
|
||||||
|
if (!string.IsNullOrWhiteSpace(cleanSector))
|
||||||
|
{
|
||||||
|
await UpdateSectorSummaryAsync(cleanSector, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return articleSentiment;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UpdateCompanySummaryWithRetryAsync(
|
||||||
|
string isin,
|
||||||
|
string companyName,
|
||||||
|
string? sector,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
const int maxRetries = 5;
|
||||||
|
for (int attempt = 1; attempt <= maxRetries; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
double halfLifeDays = 7.0;
|
||||||
|
int windowDays = 30;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var settings = scope.ServiceProvider.GetRequiredService<ISettingsService>();
|
||||||
|
halfLifeDays = await settings.GetSettingAsync(SettingKeys.TimeDecayHalfLifeDays, ct);
|
||||||
|
windowDays = await settings.GetSettingAsync(SettingKeys.SentimentWindowDays, ct);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
var cutoffDate = DateTime.UtcNow.AddDays(-windowDays);
|
||||||
|
|
||||||
|
// Query all recent sentiment analyses for this asset
|
||||||
|
var recentAnalyses = await _context.ArticleSentiments
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(a => a.Isin == isin && a.PublishedAtUtc >= cutoffDate)
|
||||||
|
.OrderByDescending(a => a.PublishedAtUtc)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
if (recentAnalyses.Count == 0)
|
||||||
|
{
|
||||||
|
// Fallback to latest available analysis if none within window
|
||||||
|
var latest = await _context.ArticleSentiments
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(a => a.Isin == isin)
|
||||||
|
.OrderByDescending(a => a.PublishedAtUtc)
|
||||||
|
.Take(1)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
recentAnalyses = latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recentAnalyses.Count == 0) return;
|
||||||
|
|
||||||
|
// Mathematical Half-Life Time-Decay Calculation:
|
||||||
|
// lambda = ln(2) / HalfLifeDays
|
||||||
|
// Weight_i = Confidence_i * exp(-lambda * deltaDays_i)
|
||||||
|
// WeightedScore = sum(Weight_i * CompoundScore_i) / sum(Weight_i)
|
||||||
|
double lambda = Math.Log(2.0) / Math.Max(0.1, halfLifeDays);
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
double totalWeightedScore = 0.0;
|
||||||
|
double totalWeights = 0.0;
|
||||||
|
double sumRawScore = 0.0;
|
||||||
|
double sumConfidence = 0.0;
|
||||||
|
int positiveCount = 0;
|
||||||
|
int negativeCount = 0;
|
||||||
|
int neutralCount = 0;
|
||||||
|
|
||||||
|
foreach (var a in recentAnalyses)
|
||||||
|
{
|
||||||
|
double deltaDays = Math.Max(0.0, (now - a.PublishedAtUtc).TotalDays);
|
||||||
|
double timeDecay = Math.Exp(-lambda * deltaDays);
|
||||||
|
double weight = Math.Max(0.01, a.Confidence) * timeDecay;
|
||||||
|
|
||||||
|
totalWeightedScore += weight * a.CompoundScore;
|
||||||
|
totalWeights += weight;
|
||||||
|
sumRawScore += a.CompoundScore;
|
||||||
|
sumConfidence += a.Confidence;
|
||||||
|
|
||||||
|
if (a.CompoundScore >= 0.15 || a.Label == "POSITIVE") positiveCount++;
|
||||||
|
else if (a.CompoundScore <= -0.15 || a.Label == "NEGATIVE") negativeCount++;
|
||||||
|
else neutralCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
double finalWeightedScore = totalWeights > 0 ? Math.Round(totalWeightedScore / totalWeights, 4) : 0.0;
|
||||||
|
double finalAvgScore = Math.Round(sumRawScore / recentAnalyses.Count, 4);
|
||||||
|
double finalAvgConfidence = Math.Round(sumConfidence / recentAnalyses.Count, 4);
|
||||||
|
|
||||||
|
string currentLabel = finalWeightedScore switch
|
||||||
|
{
|
||||||
|
> 0.50 => "VERY_BULLISH",
|
||||||
|
> 0.15 => "BULLISH",
|
||||||
|
< -0.50 => "VERY_BEARISH",
|
||||||
|
< -0.15 => "BEARISH",
|
||||||
|
_ => "NEUTRAL"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Trend detection: Compare recent (last 7 days) vs older weighted scores
|
||||||
|
string trend = "STABLE";
|
||||||
|
var last7DaysCutoff = now.AddDays(-7);
|
||||||
|
var veryRecent = recentAnalyses.Where(a => a.PublishedAtUtc >= last7DaysCutoff).ToList();
|
||||||
|
var older = recentAnalyses.Where(a => a.PublishedAtUtc < last7DaysCutoff).ToList();
|
||||||
|
|
||||||
|
if (veryRecent.Count > 0 && older.Count > 0)
|
||||||
|
{
|
||||||
|
double recentScore = veryRecent.Average(a => a.CompoundScore);
|
||||||
|
double olderScore = older.Average(a => a.CompoundScore);
|
||||||
|
double diff = recentScore - olderScore;
|
||||||
|
if (diff > 0.20) trend = "IMPROVING";
|
||||||
|
else if (diff < -0.20) trend = "DETERIORATING";
|
||||||
|
}
|
||||||
|
|
||||||
|
var latestHighlight = recentAnalyses.FirstOrDefault(a => !string.IsNullOrWhiteSpace(a.KeyHighlight))?.KeyHighlight;
|
||||||
|
|
||||||
|
var existingSummary = await _context.CompanySentiments.FirstOrDefaultAsync(c => c.Isin == isin, ct);
|
||||||
|
if (existingSummary == null)
|
||||||
|
{
|
||||||
|
var newSummary = new CompanySentimentSummaryEntity
|
||||||
|
{
|
||||||
|
Isin = isin,
|
||||||
|
Name = companyName,
|
||||||
|
Sector = sector,
|
||||||
|
CurrentLabel = currentLabel,
|
||||||
|
AverageScore = finalAvgScore,
|
||||||
|
WeightedScore = finalWeightedScore,
|
||||||
|
AverageConfidence = finalAvgConfidence,
|
||||||
|
TotalAnalysesCount = recentAnalyses.Count,
|
||||||
|
PositiveCount = positiveCount,
|
||||||
|
NegativeCount = negativeCount,
|
||||||
|
NeutralCount = neutralCount,
|
||||||
|
LatestKeyHighlight = latestHighlight,
|
||||||
|
Trend = trend,
|
||||||
|
LastUpdatedUtc = DateTime.UtcNow,
|
||||||
|
Version = 1
|
||||||
|
};
|
||||||
|
_context.CompanySentiments.Add(newSummary);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
existingSummary.Name = !string.IsNullOrWhiteSpace(companyName) ? companyName : existingSummary.Name;
|
||||||
|
existingSummary.Sector = sector ?? existingSummary.Sector;
|
||||||
|
existingSummary.CurrentLabel = currentLabel;
|
||||||
|
existingSummary.AverageScore = finalAvgScore;
|
||||||
|
existingSummary.WeightedScore = finalWeightedScore;
|
||||||
|
existingSummary.AverageConfidence = finalAvgConfidence;
|
||||||
|
existingSummary.TotalAnalysesCount = recentAnalyses.Count;
|
||||||
|
existingSummary.PositiveCount = positiveCount;
|
||||||
|
existingSummary.NegativeCount = negativeCount;
|
||||||
|
existingSummary.NeutralCount = neutralCount;
|
||||||
|
existingSummary.LatestKeyHighlight = latestHighlight ?? existingSummary.LatestKeyHighlight;
|
||||||
|
existingSummary.Trend = trend;
|
||||||
|
existingSummary.LastUpdatedUtc = DateTime.UtcNow;
|
||||||
|
existingSummary.Version++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync(ct);
|
||||||
|
await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentDbService] Updated company summary for {Isin} ({Name}): {Label} (Weighted: {Score}, Trend: {Trend})", isin, companyName, currentLabel, finalWeightedScore, trend);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
_context.ChangeTracker.Clear();
|
||||||
|
if (attempt == maxRetries) throw;
|
||||||
|
await Task.Delay(Random.Shared.Next(50, 150) * attempt, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_context.ChangeTracker.Clear();
|
||||||
|
await _finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[SentimentDbService] Error updating company summary for {Isin} (Attempt {Attempt})", isin, attempt);
|
||||||
|
if (attempt == maxRetries) throw;
|
||||||
|
await Task.Delay(100, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UpdateSectorSummaryAsync(string sector, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var companySummaries = await _context.CompanySentiments
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(c => c.Sector == sector)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
if (companySummaries.Count == 0) return;
|
||||||
|
|
||||||
|
double avgScore = Math.Round(companySummaries.Average(c => c.WeightedScore), 4);
|
||||||
|
int totalArticles = companySummaries.Sum(c => c.TotalAnalysesCount);
|
||||||
|
int totalCompanies = companySummaries.Count;
|
||||||
|
|
||||||
|
string currentLabel = avgScore switch
|
||||||
|
{
|
||||||
|
> 0.15 => "POSITIVE",
|
||||||
|
< -0.15 => "NEGATIVE",
|
||||||
|
_ => "NEUTRAL"
|
||||||
|
};
|
||||||
|
|
||||||
|
var existing = await _context.SectorSentiments.FirstOrDefaultAsync(s => s.Sector == sector, ct);
|
||||||
|
if (existing == null)
|
||||||
|
{
|
||||||
|
_context.SectorSentiments.Add(new SectorSentimentSummaryEntity
|
||||||
|
{
|
||||||
|
Sector = sector,
|
||||||
|
CurrentLabel = currentLabel,
|
||||||
|
AverageScore = avgScore,
|
||||||
|
TotalArticlesCount = totalArticles,
|
||||||
|
TotalCompaniesCount = totalCompanies,
|
||||||
|
LastUpdatedUtc = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
existing.CurrentLabel = currentLabel;
|
||||||
|
existing.AverageScore = avgScore;
|
||||||
|
existing.TotalArticlesCount = totalArticles;
|
||||||
|
existing.TotalCompaniesCount = totalCompanies;
|
||||||
|
existing.LastUpdatedUtc = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_context.ChangeTracker.Clear();
|
||||||
|
await _finlyticLogger.LogWarningAsync(SettingKeys.SentimentChannel, ex, "[SentimentDbService] Failed to update sector summary for {Sector}", sector);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<CompanySentimentSummaryEntity?> GetCompanySentimentAsync(string isin, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(isin)) return null;
|
||||||
|
var cleanIsin = isin.Trim().ToUpperInvariant();
|
||||||
|
return await _context.CompanySentiments
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(c => c.Isin == cleanIsin, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IsinSentimentSummaryDto?> GetIsinSummaryDtoAsync(string isin, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(isin)) return null;
|
||||||
|
var cleanIsin = isin.Trim().ToUpperInvariant();
|
||||||
|
|
||||||
|
var summary = await _context.CompanySentiments
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(c => c.Isin == cleanIsin, ct);
|
||||||
|
|
||||||
|
if (summary == null) return null;
|
||||||
|
|
||||||
|
var recentAnalyses = await _context.ArticleSentiments
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(a => a.Isin == cleanIsin)
|
||||||
|
.OrderByDescending(a => a.PublishedAtUtc)
|
||||||
|
.Take(15)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return new IsinSentimentSummaryDto
|
||||||
|
{
|
||||||
|
Isin = summary.Isin,
|
||||||
|
CompanyName = summary.Name,
|
||||||
|
Sector = summary.Sector ?? "General",
|
||||||
|
LastUpdated = summary.LastUpdatedUtc.ToString("o"),
|
||||||
|
CurrentSummary = new IsinCurrentSummary
|
||||||
|
{
|
||||||
|
CompoundScore = summary.WeightedScore,
|
||||||
|
SentimentLabel = summary.CurrentLabel,
|
||||||
|
AvgConfidence = summary.AverageConfidence,
|
||||||
|
TotalArticlesAnalyzed = summary.TotalAnalysesCount,
|
||||||
|
PositiveArticles = summary.PositiveCount,
|
||||||
|
NegativeArticles = summary.NegativeCount,
|
||||||
|
NeutralArticles = summary.NeutralCount,
|
||||||
|
Trend = summary.Trend,
|
||||||
|
KeyHighlight = summary.LatestKeyHighlight
|
||||||
|
},
|
||||||
|
Analyses = recentAnalyses.Select(a => new IsinAnalysisEntry
|
||||||
|
{
|
||||||
|
AnalysisId = a.Id.ToString(),
|
||||||
|
Timestamp = a.AnalyzedAtUtc.ToString("o"),
|
||||||
|
Article = new IsinAnalysisArticleRef
|
||||||
|
{
|
||||||
|
ArticleId = a.ArticleId.ToString(),
|
||||||
|
Title = a.Name,
|
||||||
|
Source = "FinlyticNews",
|
||||||
|
PublishedAt = a.PublishedAtUtc.ToString("o")
|
||||||
|
},
|
||||||
|
FinbertResult = new FinBertResultDto
|
||||||
|
{
|
||||||
|
Label = a.Label,
|
||||||
|
CompoundScore = a.CompoundScore,
|
||||||
|
Confidence = a.Confidence,
|
||||||
|
Impact = a.Impact,
|
||||||
|
KeyHighlight = a.KeyHighlight,
|
||||||
|
Probabilities = new FinBertProbabilities
|
||||||
|
{
|
||||||
|
Positive = a.PositiveProbability,
|
||||||
|
Negative = a.NegativeProbability,
|
||||||
|
Neutral = a.NeutralProbability
|
||||||
|
}
|
||||||
|
},
|
||||||
|
SummarySnippet = a.KeyHighlight ?? string.Empty
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<List<CompanySentimentSummaryEntity>> GetAllCompanySentimentsAsync(
|
||||||
|
int limit,
|
||||||
|
int offset,
|
||||||
|
string? sector = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var query = _context.CompanySentiments.AsNoTracking().AsQueryable();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(sector))
|
||||||
|
{
|
||||||
|
var cleanSector = sector.Trim();
|
||||||
|
query = query.Where(c => c.Sector == cleanSector);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await query
|
||||||
|
.OrderByDescending(c => c.LastUpdatedUtc)
|
||||||
|
.Skip(Math.Max(0, offset))
|
||||||
|
.Take(limit > 0 ? Math.Min(limit, 100) : 50)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<SectorSentimentSummaryDto?> GetSectorSentimentAsync(string sector, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(sector)) return null;
|
||||||
|
var cleanSector = sector.Trim();
|
||||||
|
|
||||||
|
var entity = await _context.SectorSentiments
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(s => s.Sector == cleanSector, ct);
|
||||||
|
|
||||||
|
if (entity == null) return null;
|
||||||
|
|
||||||
|
var activeIsins = await _context.CompanySentiments
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(c => c.Sector == cleanSector)
|
||||||
|
.Select(c => c.Isin)
|
||||||
|
.Take(20)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return new SectorSentimentSummaryDto
|
||||||
|
{
|
||||||
|
Sector = entity.Sector,
|
||||||
|
LastUpdated = entity.LastUpdatedUtc.ToString("o"),
|
||||||
|
CurrentSummary = new SectorCurrentSummary
|
||||||
|
{
|
||||||
|
CompoundScore = entity.AverageScore,
|
||||||
|
SentimentLabel = entity.CurrentLabel,
|
||||||
|
ActiveIsins = activeIsins,
|
||||||
|
TotalArticlesAnalyzed = entity.TotalArticlesCount
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<List<ArticleSentimentEntity>> GetArticleSentimentsAsync(Guid articleId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (articleId == Guid.Empty) return [];
|
||||||
|
return await _context.ArticleSentiments
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(a => a.ArticleId == articleId)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IsinAnalysisEntry?> GetArticleSentimentEntryAsync(Guid articleId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (articleId == Guid.Empty) return null;
|
||||||
|
|
||||||
|
var a = await _context.ArticleSentiments
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(x => x.ArticleId == articleId, ct);
|
||||||
|
|
||||||
|
if (a == null) return null;
|
||||||
|
|
||||||
|
return new IsinAnalysisEntry
|
||||||
|
{
|
||||||
|
AnalysisId = a.Id.ToString(),
|
||||||
|
Timestamp = a.AnalyzedAtUtc.ToString("o"),
|
||||||
|
Article = new IsinAnalysisArticleRef
|
||||||
|
{
|
||||||
|
ArticleId = a.ArticleId.ToString(),
|
||||||
|
Title = a.Name,
|
||||||
|
Source = "FinlyticNews",
|
||||||
|
PublishedAt = a.PublishedAtUtc.ToString("o")
|
||||||
|
},
|
||||||
|
FinbertResult = new FinBertResultDto
|
||||||
|
{
|
||||||
|
Label = a.Label,
|
||||||
|
CompoundScore = a.CompoundScore,
|
||||||
|
Confidence = a.Confidence,
|
||||||
|
Impact = a.Impact,
|
||||||
|
KeyHighlight = a.KeyHighlight,
|
||||||
|
Probabilities = new FinBertProbabilities
|
||||||
|
{
|
||||||
|
Positive = a.PositiveProbability,
|
||||||
|
Negative = a.NegativeProbability,
|
||||||
|
Neutral = a.NeutralProbability
|
||||||
|
}
|
||||||
|
},
|
||||||
|
SummarySnippet = a.KeyHighlight ?? string.Empty
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<List<ArticleSentimentEntity>> GetSentimentTimelineAsync(string isin, int days, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(isin)) return [];
|
||||||
|
var cleanIsin = isin.Trim().ToUpperInvariant();
|
||||||
|
var cutoff = DateTime.UtcNow.AddDays(-Math.Max(1, days));
|
||||||
|
|
||||||
|
return await _context.ArticleSentiments
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(a => a.Isin == cleanIsin && a.PublishedAtUtc >= cutoff)
|
||||||
|
.OrderByDescending(a => a.PublishedAtUtc)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,345 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
namespace FinlyticSentiment.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Defines the persistence contract for maintaining two-stage ISIN and Sector JSON sentiment summaries in the file system.
|
|
||||||
/// </summary>
|
|
||||||
public interface ISentimentStorageService
|
|
||||||
{
|
|
||||||
Task UpdateIsinSummaryAsync(string isin, string companyName, string sector, NewsArticleDto article, FinBertResultDto finbert);
|
|
||||||
Task UpdateSectorSummaryAsync(string sector, string isin, string articleId, FinBertResultDto finbert);
|
|
||||||
Task SaveArticleSentimentAsync(NewsArticleDto article, FinBertResultDto finbert);
|
|
||||||
Task<IsinAnalysisEntry?> GetArticleSentimentAsync(string articleId);
|
|
||||||
Task<IsinSentimentSummaryDto?> GetIsinSummaryAsync(string isin);
|
|
||||||
}
|
|
||||||
|
|
||||||
public class SentimentStorageService : ISentimentStorageService
|
|
||||||
{
|
|
||||||
private static readonly ConcurrentDictionary<string, SemaphoreSlim> FileLocks = new();
|
|
||||||
|
|
||||||
private readonly IFinlyticLogger<SentimentStorageService> _finlyticLogger;
|
|
||||||
private readonly string _basePath;
|
|
||||||
private readonly JsonSerializerOptions _jsonOptions;
|
|
||||||
|
|
||||||
public SentimentStorageService(IConfiguration configuration, IFinlyticLogger<SentimentStorageService> finlyticLogger)
|
|
||||||
{
|
|
||||||
_finlyticLogger = finlyticLogger;
|
|
||||||
_basePath = configuration["Storage:SummariesPath"] ?? "data/summaries";
|
|
||||||
|
|
||||||
Directory.CreateDirectory(Path.Combine(_basePath, "isin"));
|
|
||||||
Directory.CreateDirectory(Path.Combine(_basePath, "sectors"));
|
|
||||||
Directory.CreateDirectory(Path.Combine(_basePath, "articles"));
|
|
||||||
|
|
||||||
_jsonOptions = new JsonSerializerOptions
|
|
||||||
{
|
|
||||||
WriteIndented = true,
|
|
||||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task SaveArticleSentimentAsync(NewsArticleDto article, FinBertResultDto finbert)
|
|
||||||
{
|
|
||||||
if (article == null || article.Id == Guid.Empty) return;
|
|
||||||
|
|
||||||
string cleanId = article.Id.ToString();
|
|
||||||
string filePath = Path.Combine(_basePath, "articles", $"{cleanId}.json");
|
|
||||||
|
|
||||||
var fileLock = FileLocks.GetOrAdd(filePath, _ => new SemaphoreSlim(1, 1));
|
|
||||||
await fileLock.WaitAsync();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
string nowIso = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ");
|
|
||||||
var entry = new IsinAnalysisEntry
|
|
||||||
{
|
|
||||||
AnalysisId = $"sent_{Guid.NewGuid():N}",
|
|
||||||
Timestamp = nowIso,
|
|
||||||
Article = new IsinAnalysisArticleRef
|
|
||||||
{
|
|
||||||
ArticleId = cleanId,
|
|
||||||
Title = article.Title,
|
|
||||||
PublishedAt = article.PublishedAt.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
|
||||||
Source = article.Author ?? "FinlyticNews"
|
|
||||||
},
|
|
||||||
FinbertResult = finbert,
|
|
||||||
SummarySnippet = finbert.SummarySnippet ?? article.Summary ?? string.Empty
|
|
||||||
};
|
|
||||||
|
|
||||||
var json = JsonSerializer.Serialize(entry, _jsonOptions);
|
|
||||||
await File.WriteAllTextAsync(filePath, json);
|
|
||||||
await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentStorageService] Successfully saved article sentiment file: {Path}", filePath);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
await _finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[SentimentStorageService] Failed to write article sentiment file: {Path}", filePath);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
fileLock.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<IsinAnalysisEntry?> GetArticleSentimentAsync(string articleId)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(articleId)) return null;
|
|
||||||
|
|
||||||
string cleanId = articleId.Trim();
|
|
||||||
string articleFilePath = Path.Combine(_basePath, "articles", $"{cleanId}.json");
|
|
||||||
|
|
||||||
if (File.Exists(articleFilePath))
|
|
||||||
{
|
|
||||||
var fileLock = FileLocks.GetOrAdd(articleFilePath, _ => new SemaphoreSlim(1, 1));
|
|
||||||
await fileLock.WaitAsync();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var json = await File.ReadAllTextAsync(articleFilePath);
|
|
||||||
return JsonSerializer.Deserialize<IsinAnalysisEntry>(json, _jsonOptions);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
await _finlyticLogger.LogWarningAsync(SettingKeys.SentimentChannel, ex, "[SentimentStorageService] Failed to read article sentiment file: {Path}", articleFilePath);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
fileLock.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<IsinSentimentSummaryDto?> GetIsinSummaryAsync(string isin)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(isin)) return null;
|
|
||||||
|
|
||||||
string cleanIsin = isin.Trim().ToUpperInvariant();
|
|
||||||
string filePath = Path.Combine(_basePath, "isin", $"{cleanIsin}.json");
|
|
||||||
|
|
||||||
if (!File.Exists(filePath)) return null;
|
|
||||||
|
|
||||||
var fileLock = FileLocks.GetOrAdd(filePath, _ => new SemaphoreSlim(1, 1));
|
|
||||||
await fileLock.WaitAsync();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var json = await File.ReadAllTextAsync(filePath);
|
|
||||||
return JsonSerializer.Deserialize<IsinSentimentSummaryDto>(json, _jsonOptions);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
await _finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[SentimentStorageService] Error reading ISIN summary file: {Path}", filePath);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
fileLock.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task UpdateIsinSummaryAsync(string isin, string companyName, string sector, NewsArticleDto article, FinBertResultDto finbert)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(isin)) return;
|
|
||||||
|
|
||||||
string cleanIsin = isin.Trim().ToUpperInvariant();
|
|
||||||
string filePath = Path.Combine(_basePath, "isin", $"{cleanIsin}.json");
|
|
||||||
|
|
||||||
var fileLock = FileLocks.GetOrAdd(filePath, _ => new SemaphoreSlim(1, 1));
|
|
||||||
await fileLock.WaitAsync();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var analyses = new List<IsinAnalysisEntry>();
|
|
||||||
if (File.Exists(filePath))
|
|
||||||
{
|
|
||||||
var existingJson = await File.ReadAllTextAsync(filePath);
|
|
||||||
var existing = JsonSerializer.Deserialize<IsinSentimentSummaryDto>(existingJson, _jsonOptions);
|
|
||||||
if (existing?.Analyses != null)
|
|
||||||
{
|
|
||||||
analyses.AddRange(existing.Analyses);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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");
|
|
||||||
var newEntry = new IsinAnalysisEntry
|
|
||||||
{
|
|
||||||
AnalysisId = $"sent_{Guid.NewGuid():N}",
|
|
||||||
Timestamp = nowIso,
|
|
||||||
Article = new IsinAnalysisArticleRef
|
|
||||||
{
|
|
||||||
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 ?? string.Empty
|
|
||||||
};
|
|
||||||
|
|
||||||
analyses.Add(newEntry);
|
|
||||||
|
|
||||||
var cutoff = DateTime.UtcNow.AddDays(-14);
|
|
||||||
var validAnalyses = analyses.Where(a =>
|
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
int total = updatedAnalyses.Count;
|
|
||||||
double avgCompound = total > 0 ? totalCompound / total : 0.0;
|
|
||||||
double avgConf = total > 0 ? totalConf / total : 0.0;
|
|
||||||
|
|
||||||
string overallLabel = "NEUTRAL";
|
|
||||||
if (avgCompound >= 0.15) overallLabel = "POSITIVE";
|
|
||||||
else if (avgCompound <= -0.15) overallLabel = "NEGATIVE";
|
|
||||||
|
|
||||||
var summary = new IsinSentimentSummaryDto
|
|
||||||
{
|
|
||||||
Isin = cleanIsin,
|
|
||||||
CompanyName = companyName,
|
|
||||||
Sector = sector,
|
|
||||||
LastUpdated = nowIso,
|
|
||||||
CurrentSummary = new IsinCurrentSummary
|
|
||||||
{
|
|
||||||
CompoundScore = Math.Round(avgCompound, 4),
|
|
||||||
SentimentLabel = overallLabel,
|
|
||||||
AvgConfidence = Math.Round(avgConf, 4),
|
|
||||||
TotalArticlesAnalyzed = total,
|
|
||||||
Text = $"Synthesized sentiment across {total} articles is {overallLabel}."
|
|
||||||
},
|
|
||||||
Analyses = updatedAnalyses
|
|
||||||
};
|
|
||||||
|
|
||||||
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
|
|
||||||
{
|
|
||||||
fileLock.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task UpdateSectorSummaryAsync(string sector, string isin, string articleId, FinBertResultDto finbert)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(sector)) return;
|
|
||||||
|
|
||||||
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
|
|
||||||
{
|
|
||||||
var analyses = new List<SectorAnalysisEntry>();
|
|
||||||
if (File.Exists(filePath))
|
|
||||||
{
|
|
||||||
var existingJson = await File.ReadAllTextAsync(filePath);
|
|
||||||
var existing = JsonSerializer.Deserialize<SectorSentimentSummaryDto>(existingJson, _jsonOptions);
|
|
||||||
if (existing?.Analyses != null)
|
|
||||||
{
|
|
||||||
analyses.AddRange(existing.Analyses);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
string cleanIsin = isin.Trim().ToUpperInvariant();
|
|
||||||
string nowIso = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ");
|
|
||||||
|
|
||||||
analyses.RemoveAll(a => string.Equals(a.ArticleId, articleId, StringComparison.OrdinalIgnoreCase) && string.Equals(a.RelatedIsin, cleanIsin, StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
analyses.Add(new SectorAnalysisEntry
|
|
||||||
{
|
|
||||||
AnalysisId = $"sec_{Guid.NewGuid():N}",
|
|
||||||
Timestamp = nowIso,
|
|
||||||
RelatedIsin = cleanIsin,
|
|
||||||
ArticleId = articleId,
|
|
||||||
FinbertResult = finbert
|
|
||||||
});
|
|
||||||
|
|
||||||
var cutoff = DateTime.UtcNow.AddDays(-14);
|
|
||||||
var updatedAnalyses = analyses.Where(a =>
|
|
||||||
{
|
|
||||||
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.IsNullOrEmpty(i)).Distinct().ToList();
|
|
||||||
double totalSectorCompound = updatedAnalyses.Sum(s => s.FinbertResult.CompoundScore);
|
|
||||||
double avgSectorCompound = updatedAnalyses.Count > 0 ? totalSectorCompound / updatedAnalyses.Count : 0.0;
|
|
||||||
|
|
||||||
string sectorLabel = "NEUTRAL";
|
|
||||||
if (avgSectorCompound >= 0.15) sectorLabel = "POSITIVE";
|
|
||||||
else if (avgSectorCompound <= -0.15) sectorLabel = "NEGATIVE";
|
|
||||||
|
|
||||||
var summary = new SectorSentimentSummaryDto
|
|
||||||
{
|
|
||||||
Sector = sector,
|
|
||||||
LastUpdated = nowIso,
|
|
||||||
CurrentSummary = new SectorCurrentSummary
|
|
||||||
{
|
|
||||||
CompoundScore = Math.Round(avgSectorCompound, 4),
|
|
||||||
SentimentLabel = sectorLabel,
|
|
||||||
ActiveIsins = activeIsins,
|
|
||||||
Text = $"Sector {sector} aggregate sentiment: {sectorLabel}."
|
|
||||||
},
|
|
||||||
Analyses = updatedAnalyses
|
|
||||||
};
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using FinlyticSentiment.Database;
|
|
||||||
using FinlyticSentiment.Entities;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace FinlyticSentiment.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Interface for reading and persisting FinlyticSentiment runtime configuration settings in PostgreSQL.
|
|
||||||
/// </summary>
|
|
||||||
public interface ISettingsDbService
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieves current sentiment settings from PostgreSQL database, seeding defaults if empty.
|
|
||||||
/// </summary>
|
|
||||||
Task<SentimentSettingsEntity> GetSettingsAsync();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Persists updated settings entity to PostgreSQL.
|
|
||||||
/// </summary>
|
|
||||||
Task<SentimentSettingsEntity> SaveSettingsAsync(SentimentSettingsEntity settings);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates settings from a key-value dictionary received via Admin Panel MQTT events.
|
|
||||||
/// </summary>
|
|
||||||
Task UpdateSettingsFromDictionaryAsync(Dictionary<string, string> dictionary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// EF Core PostgreSQL implementation of <see cref="ISettingsDbService"/>.
|
|
||||||
/// </summary>
|
|
||||||
public class SettingsDbService : ISettingsDbService
|
|
||||||
{
|
|
||||||
private readonly SentimentDbContext _context;
|
|
||||||
private readonly ILogger<SettingsDbService> _logger;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the <see cref="SettingsDbService"/> class.
|
|
||||||
/// </summary>
|
|
||||||
public SettingsDbService(SentimentDbContext context, ILogger<SettingsDbService> logger)
|
|
||||||
{
|
|
||||||
_context = context;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieves current sentiment settings from PostgreSQL database.
|
|
||||||
/// </summary>
|
|
||||||
public async Task<SentimentSettingsEntity> GetSettingsAsync()
|
|
||||||
{
|
|
||||||
var settings = await _context.Settings.AsNoTracking().FirstOrDefaultAsync();
|
|
||||||
if (settings == null)
|
|
||||||
{
|
|
||||||
settings = new SentimentSettingsEntity { Id = Guid.NewGuid() };
|
|
||||||
_context.Settings.Add(settings);
|
|
||||||
await _context.SaveChangesAsync();
|
|
||||||
_context.ChangeTracker.Clear();
|
|
||||||
}
|
|
||||||
return settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Persists updated settings entity to PostgreSQL.
|
|
||||||
/// </summary>
|
|
||||||
public async Task<SentimentSettingsEntity> SaveSettingsAsync(SentimentSettingsEntity settings)
|
|
||||||
{
|
|
||||||
var existing = await _context.Settings.FirstOrDefaultAsync();
|
|
||||||
if (existing == null)
|
|
||||||
{
|
|
||||||
if (settings.Id == Guid.Empty) settings.Id = Guid.NewGuid();
|
|
||||||
_context.Settings.Add(settings);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
existing.MinConfidenceScore = settings.MinConfidenceScore;
|
|
||||||
existing.MaxBatchSize = settings.MaxBatchSize;
|
|
||||||
existing.SweepIntervalMinutes = settings.SweepIntervalMinutes;
|
|
||||||
existing.GermanWebhookUrl = settings.GermanWebhookUrl;
|
|
||||||
existing.EnglishWebhookUrl = settings.EnglishWebhookUrl;
|
|
||||||
existing.UpdatedAt = settings.UpdatedAt;
|
|
||||||
_context.Settings.Update(existing);
|
|
||||||
}
|
|
||||||
await _context.SaveChangesAsync();
|
|
||||||
return settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates settings from a key-value dictionary.
|
|
||||||
/// </summary>
|
|
||||||
public async Task UpdateSettingsFromDictionaryAsync(Dictionary<string, string> dictionary)
|
|
||||||
{
|
|
||||||
var settings = await GetSettingsAsync();
|
|
||||||
|
|
||||||
foreach (var (key, value) in dictionary)
|
|
||||||
{
|
|
||||||
if (string.Equals(key, "MinConfidenceScore", StringComparison.OrdinalIgnoreCase) && double.TryParse(value, out var mcs))
|
|
||||||
settings.MinConfidenceScore = mcs;
|
|
||||||
else if (string.Equals(key, "MaxBatchSize", StringComparison.OrdinalIgnoreCase) && int.TryParse(value, out var mbs))
|
|
||||||
settings.MaxBatchSize = mbs;
|
|
||||||
else if (string.Equals(key, "SweepIntervalMinutes", StringComparison.OrdinalIgnoreCase) && int.TryParse(value, out var sim))
|
|
||||||
settings.SweepIntervalMinutes = sim;
|
|
||||||
else if (string.Equals(key, "GermanWebhookUrl", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(value))
|
|
||||||
settings.GermanWebhookUrl = value.Trim();
|
|
||||||
else if (string.Equals(key, "EnglishWebhookUrl", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(value))
|
|
||||||
settings.EnglishWebhookUrl = value.Trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
settings.UpdatedAt = DateTime.UtcNow;
|
|
||||||
await SaveSettingsAsync(settings);
|
|
||||||
_logger.LogInformation("[{Channel}] Successfully updated {Count} sentiment settings in PostgreSQL database.", "SentimentChannel", dictionary.Count);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -10,8 +11,8 @@ using FinlyticCore.Dtos.Settings;
|
|||||||
using FinlyticCore.Models;
|
using FinlyticCore.Models;
|
||||||
using FinlyticCore.Services;
|
using FinlyticCore.Services;
|
||||||
using FinlyticCore.Util;
|
using FinlyticCore.Util;
|
||||||
|
using FinlyticSentiment.Entities;
|
||||||
using FinlyticSentiment.Services;
|
using FinlyticSentiment.Services;
|
||||||
using FinlyticSentiment.Util;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
@@ -20,7 +21,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
namespace FinlyticSentiment.Util;
|
namespace FinlyticSentiment.Util;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Managed MQTT client for requesting pending news articles and updating sentiment results.
|
/// Managed MQTT client for sentiment evaluations and RPC queries.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SentimentMqttClient : ManagedMqttClient, IHostedService
|
public class SentimentMqttClient : ManagedMqttClient, IHostedService
|
||||||
{
|
{
|
||||||
@@ -43,12 +44,7 @@ public class SentimentMqttClient : ManagedMqttClient, IHostedService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task StartAsync(CancellationToken cancellationToken)
|
public async Task StartAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var config = new MqttConfiguration
|
var config = MqttConfiguration.FromConfiguration(_configuration, "FinlyticSentiment");
|
||||||
{
|
|
||||||
Host = _configuration["MQTT:Host"] ?? _configuration["MQTT__Host"] ?? "localhost",
|
|
||||||
Port = Convert.ToInt32(_configuration["MQTT:Port"] ?? _configuration["MQTT__Port"] ?? "1883"),
|
|
||||||
ClientId = $"{(_configuration["MQTT:ClientId"] ?? _configuration["MQTT__ClientId"] ?? "FinlyticSentiment")}_{Guid.NewGuid()}"
|
|
||||||
};
|
|
||||||
|
|
||||||
_logger.LogInformation("Starting Sentiment MQTT client. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId);
|
_logger.LogInformation("Starting Sentiment MQTT client. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId);
|
||||||
await ConnectAsync(config);
|
await ConnectAsync(config);
|
||||||
@@ -71,403 +67,226 @@ public class SentimentMqttClient : ManagedMqttClient, IHostedService
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override async Task OnConnectedAsync()
|
protected override async Task OnConnectedAsync()
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Sentiment MQTT client connected. Subscribing to RPC response and broadcast topics...");
|
_logger.LogInformation("Sentiment MQTT client connected. Registering RPC topic subscriptions...");
|
||||||
await SubscribeAsync("services/response/#");
|
|
||||||
await SubscribeAsync("services/news/completed");
|
await SubscribeAsync(MqttTopics.ResponseWildcard);
|
||||||
await SubscribeAsync("services/request/sentiment_GetArticle/#");
|
await SubscribeRpcAsync<GetSentimentByIsinRequest, IsinSentimentSummaryDto?>(MqttTopics.RequestFilter(MqttTopics.Channels.SentimentGetIsin), HandleSentimentGetIsinRpcAsync);
|
||||||
await SubscribeAsync("services/request/sentiment_GetIsin/#");
|
await SubscribeRpcAsync<GetSectorSentimentRequest, SectorSentimentSummaryDto?>(MqttTopics.RequestFilter(MqttTopics.Channels.SentimentGetSector), HandleSentimentGetSectorRpcAsync);
|
||||||
await SubscribeAsync("services/request/sentiment_Analyze/#");
|
await SubscribeRpcAsync<ArticleRequest, IsinAnalysisEntry?>(MqttTopics.RequestFilter(MqttTopics.Channels.SentimentGetArticle), HandleSentimentGetArticleRpcAsync);
|
||||||
await SubscribeAsync("services/request/sentiment_settings_GetAll/#");
|
await SubscribeRpcAsync<PaginatedRequest, List<CompanySentimentSummaryEntity>>(MqttTopics.RequestFilter(MqttTopics.Channels.SentimentGetAll), HandleSentimentGetAllRpcAsync);
|
||||||
await SubscribeAsync("services/request/sentiment_settings_Update/#");
|
await SubscribeRpcAsync<JsonElement, IsinAnalysisEntry?>(MqttTopics.RequestFilter(MqttTopics.Channels.SentimentAnalyze), HandleSentimentAnalyzeRpcAsync);
|
||||||
await SubscribeAsync("services/request/health_Ping/#");
|
await SubscribeRpcAsync<object, List<DynamicSettingDto>>(MqttTopics.RequestFilter(MqttTopics.Channels.SentimentSettingsGetAll), HandleSettingsGetAllRpcAsync);
|
||||||
await SubscribeAsync("services/config/updated/#");
|
await SubscribeRpcAsync<Dictionary<string, object?>, List<DynamicSettingDto>>(MqttTopics.RequestFilter(MqttTopics.Channels.SentimentSettingsUpdate), HandleSettingsUpdateRpcAsync);
|
||||||
|
await SubscribeAsync<object>(MqttTopics.RequestFilter(MqttTopics.Channels.HealthPing), HandleHealthPingRpcAsync);
|
||||||
|
await SubscribeAsync<NewsArticleDto>(MqttTopics.NewsCompleted, HandleNewsCompletedBroadcastAsync);
|
||||||
|
|
||||||
FinlyticCore.Services.FinlyticLogBroadcaster.OnLogPublished = async (logDto) =>
|
FinlyticCore.Services.FinlyticLogBroadcaster.OnLogPublished = async (logDto) =>
|
||||||
{
|
{
|
||||||
if (IsConnected && string.Equals(logDto.ServiceName, "FinlyticSentiment", StringComparison.OrdinalIgnoreCase))
|
if (IsConnected && string.Equals(logDto.ServiceName, "FinlyticSentiment", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
await PublishAsync("finlytic/logs/FinlyticSentiment", logDto);
|
await PublishAsync(MqttTopics.Logs("FinlyticSentiment"), logDto);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>
|
||||||
protected override async Task OnMessageReceivedAsync(string topic, string payload)
|
/// Requests pending news articles from FinlyticNews via MQTT RPC.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<NewsArticleDto>> GetPendingArticlesAsync(int limit = 10)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(topic)) return;
|
try
|
||||||
|
|
||||||
if (topic.StartsWith("services/config/updated", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
if (topic.EndsWith("FinlyticSentiment", StringComparison.OrdinalIgnoreCase))
|
var req = new LimitRequest(limit);
|
||||||
{
|
var response = await SendRpcRequestAsync<List<NewsArticleDto>, LimitRequest>(
|
||||||
await OnConfigUpdatedAsync(payload);
|
MqttTopics.Channels.NewsGetPending,
|
||||||
|
req,
|
||||||
|
TimeSpan.FromSeconds(5)
|
||||||
|
);
|
||||||
|
return response ?? [];
|
||||||
}
|
}
|
||||||
return;
|
catch (Exception ex)
|
||||||
}
|
|
||||||
|
|
||||||
if (topic.Equals("services/news/completed", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
await OnNewsCompletedAsync(payload);
|
_logger.LogError(ex, "Failed to retrieve pending articles from FinlyticNews via news_GetPending");
|
||||||
return;
|
return [];
|
||||||
}
|
|
||||||
|
|
||||||
var lastSlash = topic.LastIndexOf('/');
|
|
||||||
if (lastSlash < 0 || lastSlash >= topic.Length - 1) return;
|
|
||||||
|
|
||||||
var correlationId = topic.Substring(lastSlash + 1);
|
|
||||||
|
|
||||||
if (topic.StartsWith("services/request/sentiment_GetArticle", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
await OnSentimentGetArticleAsync(payload, correlationId);
|
|
||||||
}
|
|
||||||
else if (topic.StartsWith("services/request/sentiment_GetIsin", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
await OnSentimentGetIsinAsync(payload, correlationId);
|
|
||||||
}
|
|
||||||
else if (topic.StartsWith("services/request/sentiment_Analyze", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
await OnSentimentAnalyzeAsync(payload, correlationId);
|
|
||||||
}
|
|
||||||
else if (topic.StartsWith("services/request/sentiment_settings_GetAll", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
await OnSettingsGetAllAsync(correlationId);
|
|
||||||
}
|
|
||||||
else if (topic.StartsWith("services/request/sentiment_settings_Update", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
await OnSettingsUpdateAsync(payload, correlationId);
|
|
||||||
}
|
|
||||||
else if (topic.StartsWith("services/request/health_Ping", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
await OnHealthPingAsync(topic, correlationId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OnSettingsGetAllAsync(string correlationId)
|
/// <summary>
|
||||||
|
/// Updates the article processing status in FinlyticNews.
|
||||||
|
/// </summary>
|
||||||
|
public async Task UpdateArticleStatusAsync(Guid articleId, string status)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var req = new UpdateNewsStatusRequest(articleId, status);
|
||||||
|
await SendRpcRequestAsync<UpdateNewsStatusResponse, UpdateNewsStatusRequest>(
|
||||||
|
MqttTopics.Channels.NewsUpdateStatus,
|
||||||
|
req,
|
||||||
|
TimeSpan.FromSeconds(5)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to update article status for {ArticleId} to '{Status}'", articleId, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Broadcasts an updated sentiment result for an asset over MQTT.
|
||||||
|
/// </summary>
|
||||||
|
public async Task BroadcastSentimentResultAsync(string isin, IsinSentimentSummaryDto summary)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(isin) || summary == null) return;
|
||||||
|
await PublishAsync(MqttTopics.SentimentStream(isin), summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IsinSentimentSummaryDto?> HandleSentimentGetIsinRpcAsync(GetSentimentByIsinRequest? req, string correlationId)
|
||||||
|
{
|
||||||
|
var targetIsin = req?.Isin;
|
||||||
|
if (string.IsNullOrWhiteSpace(targetIsin)) return null;
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<SentimentMqttClient>>();
|
||||||
|
var dbService = scope.ServiceProvider.GetRequiredService<ISentimentDbService>();
|
||||||
|
|
||||||
|
await finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentMqttClient] Processing RPC sentiment_GetIsin for {Isin} [CorrelationId: {CorrelationId}]", targetIsin, correlationId);
|
||||||
|
return await dbService.GetIsinSummaryDtoAsync(targetIsin);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SectorSentimentSummaryDto?> HandleSentimentGetSectorRpcAsync(GetSectorSentimentRequest? req, string correlationId)
|
||||||
|
{
|
||||||
|
var sector = req?.Sector;
|
||||||
|
if (string.IsNullOrWhiteSpace(sector)) return null;
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<SentimentMqttClient>>();
|
||||||
|
var dbService = scope.ServiceProvider.GetRequiredService<ISentimentDbService>();
|
||||||
|
|
||||||
|
await finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentMqttClient] Processing RPC sentiment_GetSector for {Sector} [CorrelationId: {CorrelationId}]", sector, correlationId);
|
||||||
|
return await dbService.GetSectorSentimentAsync(sector);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IsinAnalysisEntry?> HandleSentimentGetArticleRpcAsync(ArticleRequest? req, string correlationId)
|
||||||
|
{
|
||||||
|
var targetId = req?.ArticleId ?? req?.Id;
|
||||||
|
if (string.IsNullOrWhiteSpace(targetId) || !Guid.TryParse(targetId, out var articleId))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<SentimentMqttClient>>();
|
||||||
|
var dbService = scope.ServiceProvider.GetRequiredService<ISentimentDbService>();
|
||||||
|
|
||||||
|
await finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentMqttClient] Processing RPC sentiment_GetArticle for {ArticleId} [CorrelationId: {CorrelationId}]", articleId, correlationId);
|
||||||
|
return await dbService.GetArticleSentimentEntryAsync(articleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<CompanySentimentSummaryEntity>> HandleSentimentGetAllRpcAsync(PaginatedRequest? req, string correlationId)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<SentimentMqttClient>>();
|
||||||
|
var dbService = scope.ServiceProvider.GetRequiredService<ISentimentDbService>();
|
||||||
|
|
||||||
|
await finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentMqttClient] Processing RPC sentiment_GetAll [CorrelationId: {CorrelationId}]", correlationId);
|
||||||
|
return await dbService.GetAllCompanySentimentsAsync(req?.Limit ?? 50, req?.Offset ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IsinAnalysisEntry?> HandleSentimentAnalyzeRpcAsync(JsonElement rawPayload, string correlationId)
|
||||||
|
{
|
||||||
|
if (rawPayload.ValueKind == JsonValueKind.Undefined || rawPayload.ValueKind == JsonValueKind.Null) return null;
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<SentimentMqttClient>>();
|
||||||
|
var analyzer = scope.ServiceProvider.GetRequiredService<IFinBertAnalyzerService>();
|
||||||
|
var dbService = scope.ServiceProvider.GetRequiredService<ISentimentDbService>();
|
||||||
|
|
||||||
|
NewsArticleDto? article = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var rawText = rawPayload.GetRawText();
|
||||||
|
if (rawPayload.TryGetProperty("contentRaw", out _) || rawPayload.TryGetProperty("sourceUrl", out _))
|
||||||
|
{
|
||||||
|
article = JsonSerializer.Deserialize<NewsArticleDto>(rawText);
|
||||||
|
}
|
||||||
|
else if (rawPayload.TryGetProperty("articleId", out var articleIdProp))
|
||||||
|
{
|
||||||
|
var articleIdStr = articleIdProp.GetString();
|
||||||
|
if (Guid.TryParse(articleIdStr, out var parsedGuid))
|
||||||
|
{
|
||||||
|
article = await SendRpcRequestAsync<NewsArticleDto, ArticleRequest>(
|
||||||
|
MqttTopics.Channels.NewsGetById,
|
||||||
|
new ArticleRequest(articleIdStr, articleIdStr),
|
||||||
|
TimeSpan.FromSeconds(5)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await finlyticLogger.LogWarningAsync(SettingKeys.SentimentChannel, ex, "[SentimentMqttClient] Failed to deserialize payload in sentiment_Analyze");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (article == null || article.Id == Guid.Empty) return null;
|
||||||
|
|
||||||
|
await finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentMqttClient] Processing RPC sentiment_Analyze for {ArticleId} [CorrelationId: {CorrelationId}]", article.Id, correlationId);
|
||||||
|
var result = await analyzer.AnalyzeArticleAsync(article);
|
||||||
|
|
||||||
|
if (result != null && article.MatchedAssets != null)
|
||||||
|
{
|
||||||
|
foreach (var asset in article.MatchedAssets)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(asset.Isin)) continue;
|
||||||
|
await dbService.SaveArticleSentimentAsync(article.Id, asset.Isin, asset.Name, null, article.PublishedAt, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await dbService.GetArticleSentimentEntryAsync(article.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<DynamicSettingDto>> HandleSettingsGetAllRpcAsync(object? _, string correlationId)
|
||||||
{
|
{
|
||||||
using var scope = _scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<SentimentMqttClient>>();
|
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<SentimentMqttClient>>();
|
||||||
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
|
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
|
||||||
|
|
||||||
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticSentiment] [Settings_GetAll] Retrieving all dynamic settings via reflection [CorrelationId: {CorrelationId}]", correlationId);
|
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticSentiment] [Settings_GetAll] Retrieving service dynamic settings [CorrelationId: {CorrelationId}]", correlationId);
|
||||||
try
|
return await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SettingKeys) });
|
||||||
{
|
|
||||||
var settings = await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SettingKeys) });
|
|
||||||
var responseTopic = $"services/response/sentiment_settings_GetAll/{correlationId}";
|
|
||||||
|
|
||||||
await PublishAsync(responseTopic, settings);
|
|
||||||
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticSentiment] [Settings_GetAll] Published {Count} settings to '{ResponseTopic}'", settings.Count, responseTopic);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
await finlyticLogger.LogErrorAsync(SettingKeys.MqttChannel, ex, "[FinlyticSentiment] [Settings_GetAll] Failed to retrieve settings.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OnSettingsUpdateAsync(string payload, string correlationId)
|
private async Task<List<DynamicSettingDto>> HandleSettingsUpdateRpcAsync(Dictionary<string, object?>? updates, string correlationId)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(payload)) return;
|
|
||||||
|
|
||||||
using var scope = _scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<SentimentMqttClient>>();
|
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<SentimentMqttClient>>();
|
||||||
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
|
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
|
||||||
|
|
||||||
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticSentiment] [Settings_Update] Processing settings update RPC [CorrelationId: {CorrelationId}]", correlationId);
|
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticSentiment] [Settings_Update] Processing settings update RPC [CorrelationId: {CorrelationId}]", correlationId);
|
||||||
try
|
|
||||||
{
|
|
||||||
Dictionary<string, object?>? updates = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
updates = JsonSerializer.Deserialize<Dictionary<string, object?>>(payload);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
var list = JsonSerializer.Deserialize<List<DynamicSettingDto>>(payload);
|
|
||||||
if (list != null)
|
|
||||||
{
|
|
||||||
updates = new Dictionary<string, object?>();
|
|
||||||
foreach (var item in list) updates[item.Key] = item.Value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updates != null && updates.Count > 0)
|
if (updates != null && updates.Count > 0)
|
||||||
{
|
{
|
||||||
await settingsService.UpdateSettingsAsync(updates);
|
await settingsService.UpdateSettingsAsync(updates);
|
||||||
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticSentiment] [Settings_Update] Successfully updated {Count} settings in database and cache.", updates.Count);
|
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticSentiment] [Settings_Update] Successfully updated {Count} settings in database and cache.", updates.Count);
|
||||||
}
|
}
|
||||||
|
return await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SettingKeys) });
|
||||||
var currentSettings = await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SettingKeys) });
|
|
||||||
var responseTopic = $"services/response/sentiment_settings_Update/{correlationId}";
|
|
||||||
await PublishAsync(responseTopic, currentSettings);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
await finlyticLogger.LogErrorAsync(SettingKeys.MqttChannel, ex, "[FinlyticSentiment] [Settings_Update] Failed to update settings.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OnConfigUpdatedAsync(string payload)
|
private async Task HandleHealthPingRpcAsync(object? _, string topic, string correlationId)
|
||||||
{
|
{
|
||||||
try
|
if (topic.Contains("FinlyticSentiment", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
using var doc = JsonDocument.Parse(payload);
|
string respTopic = MqttTopics.ResponseTopic(MqttTopics.Channels.HealthPing, correlationId);
|
||||||
if (doc.RootElement.TryGetProperty("settings", out var settingsProp))
|
|
||||||
{
|
|
||||||
var dict = JsonSerializer.Deserialize<Dictionary<string, object?>>(settingsProp.GetRawText());
|
|
||||||
if (dict != null && dict.Count > 0)
|
|
||||||
{
|
|
||||||
using var scope = _scopeFactory.CreateScope();
|
|
||||||
var settings = scope.ServiceProvider.GetRequiredService<ISettingsService>();
|
|
||||||
await settings.UpdateSettingsAsync(dict);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task OnHealthPingAsync(string topic, string correlationId)
|
|
||||||
{
|
|
||||||
if (topic.Contains("FinlyticSentiment", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
!topic.Contains("/", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
string respTopic = $"services/response/health_Ping/{correlationId}";
|
|
||||||
await PublishAsync(respTopic, new ServiceHealthResponse("FinlyticSentiment", "Online", DateTime.UtcNow, "Connected"));
|
await PublishAsync(respTopic, new ServiceHealthResponse("FinlyticSentiment", "Online", DateTime.UtcNow, "Connected"));
|
||||||
|
|
||||||
using var scope = _scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<SentimentMqttClient>>();
|
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<SentimentMqttClient>>();
|
||||||
await finlyticLogger.LogInfoAsync(SettingKeys.HealthPingChannel, "[FinlyticSentiment] Responded to live health_Ping RPC request [CorrelationId: {CorrelationId}].", correlationId);
|
await finlyticLogger.LogInfoAsync(SettingKeys.HealthPingChannel, "Responded to live health_Ping RPC request [CorrelationId: {CorrelationId}].", correlationId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OnNewsCompletedAsync(string payload)
|
private async Task HandleNewsCompletedBroadcastAsync(NewsArticleDto? article, string topic, string correlationId)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(payload)) return;
|
if (article != null && OnArticleReceived != null)
|
||||||
try
|
|
||||||
{
|
{
|
||||||
var article = JsonSerializer.Deserialize(payload, typeof(NewsArticleDto), FinlyticJsonSerializerContext.Default) as NewsArticleDto;
|
|
||||||
if (article != null && article.Id != Guid.Empty && OnArticleReceived != null)
|
|
||||||
{
|
|
||||||
using var scope = _scopeFactory.CreateScope();
|
|
||||||
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<SentimentMqttClient>>();
|
|
||||||
await finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "Received real-time article broadcast on services/news/completed: {Title} (ID: {Id})", article.Title, article.Id);
|
|
||||||
await OnArticleReceived.Invoke(article);
|
await OnArticleReceived.Invoke(article);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch { }
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task OnSentimentGetArticleAsync(string payload, string correlationId)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(payload)) return;
|
|
||||||
|
|
||||||
using var scope = _scopeFactory.CreateScope();
|
|
||||||
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<SentimentMqttClient>>();
|
|
||||||
|
|
||||||
await finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentMqttClient] Processing RPC sentiment_GetArticle request [CorrelationId: {CorrelationId}]", correlationId);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var request = JsonSerializer.Deserialize(payload, typeof(ArticleRequest), FinlyticJsonSerializerContext.Default) as ArticleRequest;
|
|
||||||
var articleId = request?.ArticleId ?? request?.Id;
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(articleId))
|
|
||||||
{
|
|
||||||
await finlyticLogger.LogWarningAsync(SettingKeys.SentimentChannel, "[SentimentMqttClient] Missing articleId in request payload.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var storageService = scope.ServiceProvider.GetRequiredService<ISentimentStorageService>();
|
|
||||||
var sentimentEntry = await storageService.GetArticleSentimentAsync(articleId);
|
|
||||||
|
|
||||||
string responseTopic = $"services/response/sentiment_GetArticle/{correlationId}";
|
|
||||||
await finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentMqttClient] Publishing RPC sentiment_GetArticle response for article {ArticleId} to {ResponseTopic}", articleId, responseTopic);
|
|
||||||
await PublishAsync(responseTopic, sentimentEntry);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
await finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[SentimentMqttClient] Failed to process sentiment_GetArticle RPC request.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task OnSentimentGetIsinAsync(string payload, string correlationId)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(payload)) return;
|
|
||||||
|
|
||||||
using var scope = _scopeFactory.CreateScope();
|
|
||||||
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<SentimentMqttClient>>();
|
|
||||||
|
|
||||||
await finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentMqttClient] Processing RPC sentiment_GetIsin request [CorrelationId: {CorrelationId}]", correlationId);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var request = JsonSerializer.Deserialize(payload, typeof(IsinRequest), FinlyticJsonSerializerContext.Default) as IsinRequest;
|
|
||||||
var isin = request?.Isin;
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(isin))
|
|
||||||
{
|
|
||||||
await finlyticLogger.LogWarningAsync(SettingKeys.SentimentChannel, "[SentimentMqttClient] Missing ISIN in request payload.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var storageService = scope.ServiceProvider.GetRequiredService<ISentimentStorageService>();
|
|
||||||
var isinSummary = await storageService.GetIsinSummaryAsync(isin);
|
|
||||||
|
|
||||||
string responseTopic = $"services/response/sentiment_GetIsin/{correlationId}";
|
|
||||||
await finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentMqttClient] Publishing RPC sentiment_GetIsin response for ISIN {Isin} to {ResponseTopic}", isin, responseTopic);
|
|
||||||
await PublishAsync(responseTopic, isinSummary);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
await finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[SentimentMqttClient] Failed to process sentiment_GetIsin RPC request.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task OnSentimentAnalyzeAsync(string payload, string correlationId)
|
|
||||||
{
|
|
||||||
using var scope = _scopeFactory.CreateScope();
|
|
||||||
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<SentimentMqttClient>>();
|
|
||||||
|
|
||||||
await finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentMqttClient] Processing RPC sentiment_Analyze request [CorrelationId: {CorrelationId}]", correlationId);
|
|
||||||
string responseTopic = $"services/response/sentiment_Analyze/{correlationId}";
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(payload))
|
|
||||||
{
|
|
||||||
await PublishAsync(responseTopic, (object?)null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var request = JsonSerializer.Deserialize(payload, typeof(AnalyzeSentimentRequest), FinlyticJsonSerializerContext.Default) as AnalyzeSentimentRequest;
|
|
||||||
|
|
||||||
if (request == null || (string.IsNullOrWhiteSpace(request.ArticleId) && string.IsNullOrWhiteSpace(request.Isin)))
|
|
||||||
{
|
|
||||||
await finlyticLogger.LogWarningAsync(SettingKeys.SentimentChannel, "[SentimentMqttClient] Missing ArticleId or Isin in sentiment_Analyze request payload.");
|
|
||||||
await PublishAsync(responseTopic, (object?)null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var storageService = scope.ServiceProvider.GetRequiredService<ISentimentStorageService>();
|
|
||||||
var analyzerService = scope.ServiceProvider.GetRequiredService<IFinBertAnalyzerService>();
|
|
||||||
|
|
||||||
object? result = null;
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.ArticleId))
|
|
||||||
{
|
|
||||||
var cleanArticleId = request.ArticleId.Trim();
|
|
||||||
await finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentMqttClient] Processing article analysis for ArticleId: {ArticleId} (ForceReload: {ForceReload})", cleanArticleId, request.ForceReload);
|
|
||||||
|
|
||||||
IsinAnalysisEntry? existingEntry = null;
|
|
||||||
|
|
||||||
if (!request.ForceReload)
|
|
||||||
{
|
|
||||||
existingEntry = await storageService.GetArticleSentimentAsync(cleanArticleId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingEntry != null)
|
|
||||||
{
|
|
||||||
result = existingEntry;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (Guid.TryParse(cleanArticleId, out var articleGuid))
|
|
||||||
{
|
|
||||||
var article = await SendRpcRequestAsync<NewsArticleDto, ArticleRequest>(
|
|
||||||
"news_GetById",
|
|
||||||
new ArticleRequest(cleanArticleId, cleanArticleId),
|
|
||||||
TimeSpan.FromSeconds(8));
|
|
||||||
|
|
||||||
if (article != null)
|
|
||||||
{
|
|
||||||
var finbertResult = await analyzerService.AnalyzeArticleAsync(article);
|
|
||||||
|
|
||||||
if (finbertResult == null)
|
|
||||||
{
|
|
||||||
await finlyticLogger.LogWarningAsync(SettingKeys.SentimentChannel, "[SentimentMqttClient] FinBERT analysis returned NULL for article {ArticleId}. Aborting manual analysis.", cleanArticleId);
|
|
||||||
await PublishAsync(responseTopic, (object?)null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await storageService.SaveArticleSentimentAsync(article, finbertResult);
|
|
||||||
|
|
||||||
if (article.MatchedAssets != null && article.MatchedAssets.Count > 0)
|
|
||||||
{
|
|
||||||
foreach (var asset in article.MatchedAssets)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(asset.Isin)) continue;
|
|
||||||
|
|
||||||
await storageService.UpdateIsinSummaryAsync(
|
|
||||||
asset.Isin,
|
|
||||||
asset.Name,
|
|
||||||
"General",
|
|
||||||
article,
|
|
||||||
finbertResult);
|
|
||||||
|
|
||||||
await storageService.UpdateSectorSummaryAsync(
|
|
||||||
"General",
|
|
||||||
asset.Isin,
|
|
||||||
article.Id.ToString(),
|
|
||||||
finbertResult);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await UpdateArticleStatusAsync(article.Id, "Analyzed");
|
|
||||||
|
|
||||||
result = await storageService.GetArticleSentimentAsync(cleanArticleId);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await finlyticLogger.LogWarningAsync(SettingKeys.SentimentChannel, "[SentimentMqttClient] Could not retrieve article {ArticleId} from FinlyticNews for re-analysis.", cleanArticleId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (!string.IsNullOrWhiteSpace(request.Isin))
|
|
||||||
{
|
|
||||||
var cleanIsin = request.Isin.Trim();
|
|
||||||
await finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentMqttClient] Fetching sentiment summary for ISIN: {Isin}", cleanIsin);
|
|
||||||
|
|
||||||
result = await storageService.GetIsinSummaryAsync(cleanIsin);
|
|
||||||
}
|
|
||||||
|
|
||||||
await finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentMqttClient] Publishing RPC sentiment_Analyze response to {ResponseTopic}", responseTopic);
|
|
||||||
await PublishAsync(responseTopic, result);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
await finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[SentimentMqttClient] Failed to process sentiment_Analyze RPC request.");
|
|
||||||
await PublishAsync(responseTopic, (object?)null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<NewsArticleDto>> GetPendingArticlesAsync(int limit = 10)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var payload = new LimitRequest(Math.Min(limit, 10));
|
|
||||||
var articles = await SendRpcRequestAsync<List<NewsArticleDto>, LimitRequest>("news_GetPending", payload, TimeSpan.FromSeconds(10));
|
|
||||||
return articles ?? [];
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error executing MQTT RPC for news_GetPending.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> UpdateArticleStatusAsync(Guid id, string status = "Analyzed")
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var request = new UpdateNewsStatusRequest(id, status);
|
|
||||||
var response = await SendRpcRequestAsync<UpdateNewsStatusResponse, UpdateNewsStatusRequest>("news_UpdateStatus", request, TimeSpan.FromSeconds(8));
|
|
||||||
return response?.Success ?? false;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error executing MQTT RPC for news_UpdateStatus (ID: {Id}).", id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -13,8 +13,8 @@ public static class SettingKeys
|
|||||||
public static readonly SettingKey<int> MaxBatchSize = new("FinBert.MaxBatchSize", 10);
|
public static readonly SettingKey<int> MaxBatchSize = new("FinBert.MaxBatchSize", 10);
|
||||||
public static readonly SettingKey<double> MinimumConfidenceThreshold = new("FinBert.MinConfidenceThreshold", 0.60);
|
public static readonly SettingKey<double> MinimumConfidenceThreshold = new("FinBert.MinConfidenceThreshold", 0.60);
|
||||||
public static readonly SettingKey<int> TimeoutSeconds = new("FinBert.TimeoutSeconds", 30);
|
public static readonly SettingKey<int> TimeoutSeconds = new("FinBert.TimeoutSeconds", 30);
|
||||||
public static readonly SettingKey<int> SentimentWindowDays = new("Sentiment.WindowDays", 14);
|
public static readonly SettingKey<int> SentimentWindowDays = new("Sentiment.WindowDays", 30);
|
||||||
public static readonly SettingKey<double> DecayFactorPerDay = new("Sentiment.DecayFactorPerDay", 0.90);
|
public static readonly SettingKey<double> TimeDecayHalfLifeDays = new("Sentiment.TimeDecayHalfLifeDays", 7.0);
|
||||||
public static readonly SettingKey<bool> EnableAutoSummarization = new("Feature.EnableAutoSummarization", true);
|
public static readonly SettingKey<bool> EnableAutoSummarization = new("Feature.EnableAutoSummarization", true);
|
||||||
|
|
||||||
// --- N8N / Webhook-Konfiguration ---
|
// --- N8N / Webhook-Konfiguration ---
|
||||||
|
|||||||
Reference in New Issue
Block a user