From def30b71777c36279aab2a768f5bf7ca1db9748f Mon Sep 17 00:00:00 2001 From: "maxim.gorbatyuk" Date: Thu, 1 May 2025 18:22:33 +0500 Subject: [PATCH 1/8] Added jwt generator --- src/Infrastructure/Infrastructure.csproj | 1 + src/Infrastructure/Jwt/JwtTokenGenerator.cs | 53 +++++++++++++++++++ .../Jwt/JwtTokenGeneratorTests.cs | 18 +++++++ 3 files changed, 72 insertions(+) create mode 100644 src/Infrastructure/Jwt/JwtTokenGenerator.cs create mode 100644 src/InfrastructureTests/Jwt/JwtTokenGeneratorTests.cs diff --git a/src/Infrastructure/Infrastructure.csproj b/src/Infrastructure/Infrastructure.csproj index 1c1f7316..82c8a3eb 100644 --- a/src/Infrastructure/Infrastructure.csproj +++ b/src/Infrastructure/Infrastructure.csproj @@ -21,6 +21,7 @@ + diff --git a/src/Infrastructure/Jwt/JwtTokenGenerator.cs b/src/Infrastructure/Jwt/JwtTokenGenerator.cs new file mode 100644 index 00000000..9978fe81 --- /dev/null +++ b/src/Infrastructure/Jwt/JwtTokenGenerator.cs @@ -0,0 +1,53 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.IdentityModel.Tokens; + +namespace Infrastructure.Jwt; + +public record JwtTokenGenerator +{ + public const string DefaultIssuer = "techinterview-space"; + public const string DefaultAudience = "techinterview-space-bot"; + + private readonly string _secretKey; + private readonly string _issuer; + private readonly string _audience; + private readonly int _expirationMinutes; + + private string _result; + + public JwtTokenGenerator( + string secretKey, + string issuer = DefaultIssuer, + string audience = DefaultAudience, + int expirationMinutes = 60) + { + _secretKey = secretKey; + _issuer = issuer; + _audience = audience; + _expirationMinutes = expirationMinutes; + } + + public override string ToString() + { + return _result ??= GenerateInternal(); + } + + private string GenerateInternal() + { + var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secretKey)); + var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); + + var claims = new List(); + + var token = new JwtSecurityToken( + issuer: _issuer, + audience: _audience, + claims: claims, + expires: DateTime.UtcNow.AddMinutes(_expirationMinutes), + signingCredentials: credentials); + + return new JwtSecurityTokenHandler().WriteToken(token); + } +} \ No newline at end of file diff --git a/src/InfrastructureTests/Jwt/JwtTokenGeneratorTests.cs b/src/InfrastructureTests/Jwt/JwtTokenGeneratorTests.cs new file mode 100644 index 00000000..6e391aaf --- /dev/null +++ b/src/InfrastructureTests/Jwt/JwtTokenGeneratorTests.cs @@ -0,0 +1,18 @@ +using Infrastructure.Jwt; +using Xunit; + +namespace InfrastructureTests.Jwt; + +public class JwtTokenGeneratorTests +{ + [Fact] + public void ToString_ShouldReturnToken() + { + // Arrange + var secretKey = "my-secret-phrase-please-do-not-use-this-in-production"; + var generator = new JwtTokenGenerator(secretKey); + + var result = generator.ToString(); + Assert.Contains("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", result); + } +} \ No newline at end of file From 8afba7d1c91896dfbdd9551ebd28d0237f78ae34 Mon Sep 17 00:00:00 2001 From: "maxim.gorbatyuk" Date: Fri, 2 May 2025 16:41:40 +0500 Subject: [PATCH 2/8] added jwt and tests --- ...rator.cs => TechinterviewJwtTokenGenerator.cs} | 15 ++++++++++----- ....cs => TechinterviewJwtTokenGeneratorTests.cs} | 6 +++--- 2 files changed, 13 insertions(+), 8 deletions(-) rename src/Infrastructure/Jwt/{JwtTokenGenerator.cs => TechinterviewJwtTokenGenerator.cs} (74%) rename src/InfrastructureTests/Jwt/{JwtTokenGeneratorTests.cs => TechinterviewJwtTokenGeneratorTests.cs} (62%) diff --git a/src/Infrastructure/Jwt/JwtTokenGenerator.cs b/src/Infrastructure/Jwt/TechinterviewJwtTokenGenerator.cs similarity index 74% rename from src/Infrastructure/Jwt/JwtTokenGenerator.cs rename to src/Infrastructure/Jwt/TechinterviewJwtTokenGenerator.cs index 9978fe81..8a7b7cae 100644 --- a/src/Infrastructure/Jwt/JwtTokenGenerator.cs +++ b/src/Infrastructure/Jwt/TechinterviewJwtTokenGenerator.cs @@ -5,7 +5,7 @@ namespace Infrastructure.Jwt; -public record JwtTokenGenerator +public record TechinterviewJwtTokenGenerator { public const string DefaultIssuer = "techinterview-space"; public const string DefaultAudience = "techinterview-space-bot"; @@ -17,7 +17,7 @@ public record JwtTokenGenerator private string _result; - public JwtTokenGenerator( + public TechinterviewJwtTokenGenerator( string secretKey, string issuer = DefaultIssuer, string audience = DefaultAudience, @@ -37,9 +37,14 @@ public override string ToString() private string GenerateInternal() { var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secretKey)); - var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); - - var claims = new List(); + var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256Signature); + + var claims = new List + { + new Claim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString()), + new Claim(JwtRegisteredClaimNames.Name, "TechInterview.space Bot"), + new Claim(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64) + }; var token = new JwtSecurityToken( issuer: _issuer, diff --git a/src/InfrastructureTests/Jwt/JwtTokenGeneratorTests.cs b/src/InfrastructureTests/Jwt/TechinterviewJwtTokenGeneratorTests.cs similarity index 62% rename from src/InfrastructureTests/Jwt/JwtTokenGeneratorTests.cs rename to src/InfrastructureTests/Jwt/TechinterviewJwtTokenGeneratorTests.cs index 6e391aaf..2ac372c0 100644 --- a/src/InfrastructureTests/Jwt/JwtTokenGeneratorTests.cs +++ b/src/InfrastructureTests/Jwt/TechinterviewJwtTokenGeneratorTests.cs @@ -3,14 +3,14 @@ namespace InfrastructureTests.Jwt; -public class JwtTokenGeneratorTests +public class TechinterviewJwtTokenGeneratorTests { [Fact] public void ToString_ShouldReturnToken() { // Arrange - var secretKey = "my-secret-phrase-please-do-not-use-this-in-production"; - var generator = new JwtTokenGenerator(secretKey); + var secretKey = "eV)1T_9>zm7|"; + var generator = new TechinterviewJwtTokenGenerator(secretKey); var result = generator.ToString(); Assert.Contains("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", result); From c5bb686603571f85ef26e7567da6e3261798b88d Mon Sep 17 00:00:00 2001 From: "maxim.gorbatyuk" Date: Fri, 2 May 2025 16:58:41 +0500 Subject: [PATCH 3/8] Fixed --- .../Jwt/TechinterviewJwtTokenGenerator.cs | 18 ++---------------- .../Jwt/TechinterviewJwtTokenGeneratorTests.cs | 5 ++--- 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/src/Infrastructure/Jwt/TechinterviewJwtTokenGenerator.cs b/src/Infrastructure/Jwt/TechinterviewJwtTokenGenerator.cs index 8a7b7cae..ac659c4d 100644 --- a/src/Infrastructure/Jwt/TechinterviewJwtTokenGenerator.cs +++ b/src/Infrastructure/Jwt/TechinterviewJwtTokenGenerator.cs @@ -7,26 +7,14 @@ namespace Infrastructure.Jwt; public record TechinterviewJwtTokenGenerator { - public const string DefaultIssuer = "techinterview-space"; - public const string DefaultAudience = "techinterview-space-bot"; - private readonly string _secretKey; - private readonly string _issuer; - private readonly string _audience; - private readonly int _expirationMinutes; private string _result; public TechinterviewJwtTokenGenerator( - string secretKey, - string issuer = DefaultIssuer, - string audience = DefaultAudience, - int expirationMinutes = 60) + string secretKey) { _secretKey = secretKey; - _issuer = issuer; - _audience = audience; - _expirationMinutes = expirationMinutes; } public override string ToString() @@ -47,10 +35,8 @@ private string GenerateInternal() }; var token = new JwtSecurityToken( - issuer: _issuer, - audience: _audience, claims: claims, - expires: DateTime.UtcNow.AddMinutes(_expirationMinutes), + expires: DateTime.UtcNow.AddHours(1), signingCredentials: credentials); return new JwtSecurityTokenHandler().WriteToken(token); diff --git a/src/InfrastructureTests/Jwt/TechinterviewJwtTokenGeneratorTests.cs b/src/InfrastructureTests/Jwt/TechinterviewJwtTokenGeneratorTests.cs index 2ac372c0..7bec1402 100644 --- a/src/InfrastructureTests/Jwt/TechinterviewJwtTokenGeneratorTests.cs +++ b/src/InfrastructureTests/Jwt/TechinterviewJwtTokenGeneratorTests.cs @@ -8,11 +8,10 @@ public class TechinterviewJwtTokenGeneratorTests [Fact] public void ToString_ShouldReturnToken() { - // Arrange - var secretKey = "eV)1T_9>zm7|"; + const string secretKey = "hey-girl-its-super-secret-key-for-jwt"; var generator = new TechinterviewJwtTokenGenerator(secretKey); var result = generator.ToString(); - Assert.Contains("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", result); + Assert.Contains("eyJhbGciOiJodHRwOi8vd3d3LnczLm9yZy8y", result); } } \ No newline at end of file From bf73732de58ff74298b86f43875c74efae80b2e0 Mon Sep 17 00:00:00 2001 From: "maxim.gorbatyuk" Date: Fri, 2 May 2025 18:42:56 +0500 Subject: [PATCH 4/8] Added necessary classes --- .../Jwt/TechinterviewJwtTokenGenerator.cs | 2 +- .../StatDataChangeSubscriptionCalculateJob.cs | 26 ++++++++--------- .../OpenAiBodyReport.cs | 25 +++++++++++++++++ .../OpenAiBodyReportMetadata.cs | 21 ++++++++++++++ .../OpenAiBodyReportRole.cs | 10 +++++++ .../OpenAiBodyReportRoleHistoricalDataItem.cs | 10 +++++++ .../OpenAiBodyReportRoleSalaryData.cs | 28 +++++++++++++++++++ 7 files changed, 108 insertions(+), 14 deletions(-) create mode 100644 src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReport.cs create mode 100644 src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReportMetadata.cs create mode 100644 src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReportRole.cs create mode 100644 src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReportRoleHistoricalDataItem.cs create mode 100644 src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReportRoleSalaryData.cs diff --git a/src/Infrastructure/Jwt/TechinterviewJwtTokenGenerator.cs b/src/Infrastructure/Jwt/TechinterviewJwtTokenGenerator.cs index ac659c4d..5744507f 100644 --- a/src/Infrastructure/Jwt/TechinterviewJwtTokenGenerator.cs +++ b/src/Infrastructure/Jwt/TechinterviewJwtTokenGenerator.cs @@ -24,7 +24,7 @@ public override string ToString() private string GenerateInternal() { - var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_secretKey)); + var securityKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_secretKey)); var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256Signature); var claims = new List diff --git a/src/Web.Api/Features/BackgroundJobs/StatDataChangeSubscriptionCalculateJob.cs b/src/Web.Api/Features/BackgroundJobs/StatDataChangeSubscriptionCalculateJob.cs index 4de9bec4..a91d5009 100644 --- a/src/Web.Api/Features/BackgroundJobs/StatDataChangeSubscriptionCalculateJob.cs +++ b/src/Web.Api/Features/BackgroundJobs/StatDataChangeSubscriptionCalculateJob.cs @@ -73,11 +73,14 @@ public override async Task ExecuteAsync( foreach (var subscription in subscriptions) { - var lastCacheItemOrNull = await _context.StatDataChangeSubscriptionRecords + List lastCacheItems = await _context.StatDataChangeSubscriptionRecords .AsNoTracking() .Where(x => x.SubscriptionId == subscription.Id) .OrderByDescending(x => x.CreatedAt) - .FirstOrDefaultAsync(cancellationToken); + .Take(3) + .ToListAsync(cancellationToken); + + var lastCacheItemOrNull = lastCacheItems.FirstOrDefault(); var filterData = new TelegramBotUserCommandParameters( allProfessions @@ -132,20 +135,17 @@ public override async Task ExecuteAsync( line += $"{median.ToString("N0", CultureInfo.InvariantCulture)} тг. "; - if (lastCacheItemOrNull is not null) + var oldGradeValue = lastCacheItemOrNull?.Data.GetMedianLocalSalaryByGrade(gradeGroup); + if (oldGradeValue is > 0) { - var oldGradeValue = lastCacheItemOrNull.Data.GetMedianLocalSalaryByGrade(gradeGroup); - if (oldGradeValue is > 0) - { - var diffInPercent = (median - oldGradeValue.Value) / oldGradeValue.Value * 100; + var diffInPercent = (median - oldGradeValue.Value) / oldGradeValue.Value * 100; - if (diffInPercent is > 0 or < 0) - { - hasAnyDifference = hasAnyDifference || true; + if (diffInPercent is > 0 or < 0) + { + hasAnyDifference = hasAnyDifference || true; - var sign = diffInPercent > 0 ? "🔼 " : "🔻 "; - line += $"{sign}{diffInPercent.ToString("N0", CultureInfo.InvariantCulture)}%. "; - } + var sign = diffInPercent > 0 ? "🔼 " : "🔻 "; + line += $"{sign}{diffInPercent.ToString("N0", CultureInfo.InvariantCulture)}%. "; } } diff --git a/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReport.cs b/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReport.cs new file mode 100644 index 00000000..06c84d94 --- /dev/null +++ b/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReport.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using Domain.Entities.Salaries; +using Domain.Entities.StatData; + +namespace Web.Api.Integrations.OpenAiAnalysisIntegration; + +public record OpenAiBodyReport +{ + private readonly List _professions; + private readonly Currency _currency; + + public OpenAiBodyReport( + List professions, + List subscriptionStatData, + Currency currency) + { + _professions = professions; + _currency = currency; + ReportMetadata = new OpenAiBodyReportMetadata(_currency); + } + + public OpenAiBodyReportMetadata ReportMetadata { get; } + + public List Roles { get; } +} \ No newline at end of file diff --git a/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReportMetadata.cs b/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReportMetadata.cs new file mode 100644 index 00000000..d7c90abe --- /dev/null +++ b/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReportMetadata.cs @@ -0,0 +1,21 @@ +using System; +using Domain.Entities.Salaries; + +namespace Web.Api.Integrations.OpenAiAnalysisIntegration; + +public record OpenAiBodyReportMetadata +{ + public string ReportDate { get; } + + public string Currency { get; } + + public string PeriodType { get; } + + public OpenAiBodyReportMetadata( + Currency currency) + { + Currency = currency.ToString(); + ReportDate = DateTime.UtcNow.ToString("yyyy-MM-dd"); + PeriodType = "weekly"; + } +} \ No newline at end of file diff --git a/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReportRole.cs b/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReportRole.cs new file mode 100644 index 00000000..eb677b09 --- /dev/null +++ b/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReportRole.cs @@ -0,0 +1,10 @@ +namespace Web.Api.Integrations.OpenAiAnalysisIntegration; + +public record OpenAiBodyReportRole +{ + public string RoleName { get; } + + public OpenAiBodyReportRoleSalaryData CurrentSalary { get; } + + public OpenAiBodyReportRoleSalaryData HistoricalData { get; } +} \ No newline at end of file diff --git a/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReportRoleHistoricalDataItem.cs b/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReportRoleHistoricalDataItem.cs new file mode 100644 index 00000000..0a719cca --- /dev/null +++ b/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReportRoleHistoricalDataItem.cs @@ -0,0 +1,10 @@ +namespace Web.Api.Integrations.OpenAiAnalysisIntegration; + +public record OpenAiBodyReportRoleHistoricalDataItem +{ + public string Date { get; } + + public double Average { get; } + + public double PercentChange { get; } +} \ No newline at end of file diff --git a/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReportRoleSalaryData.cs b/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReportRoleSalaryData.cs new file mode 100644 index 00000000..6fbd4946 --- /dev/null +++ b/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReportRoleSalaryData.cs @@ -0,0 +1,28 @@ +namespace Web.Api.Integrations.OpenAiAnalysisIntegration; + +public record OpenAiBodyReportRoleSalaryData +{ + public OpenAiBodyReportRoleSalaryData( + double average, + double median, + double min, + double max, + int count) + { + Average = average; + Median = median; + Min = min; + Max = max; + Count = count; + } + + public double Average { get; } + + public double Median { get; } + + public double Min { get; } + + public double Max { get; } + + public int Count { get; } +} \ No newline at end of file From 86dd304fcb8f3404b010212383af01cd84bba271 Mon Sep 17 00:00:00 2001 From: "maxim.gorbatyuk" Date: Sat, 3 May 2025 07:15:02 +0500 Subject: [PATCH 5/8] Added class --- .../StatData/StatDataCacheItemSalaryData.cs | 11 ++- .../Salaries/SalariesForChartQuery.cs | 4 +- .../BackgroundJobs/SalarySubscriptionData.cs | 87 +++++++++++++++++ .../StatDataChangeSubscriptionCalculateJob.cs | 95 ++++++++----------- .../OpenAiBodyReportRoleSalaryData.cs | 4 +- 5 files changed, 143 insertions(+), 58 deletions(-) create mode 100644 src/Web.Api/Features/BackgroundJobs/SalarySubscriptionData.cs diff --git a/src/Domain/Entities/StatData/StatDataCacheItemSalaryData.cs b/src/Domain/Entities/StatData/StatDataCacheItemSalaryData.cs index e900273f..6ebaea31 100644 --- a/src/Domain/Entities/StatData/StatDataCacheItemSalaryData.cs +++ b/src/Domain/Entities/StatData/StatDataCacheItemSalaryData.cs @@ -22,10 +22,15 @@ public StatDataCacheItemSalaryData( List values, int totalCount) { - var salaryValues = values.Select(x => x.Value).ToList(); + var salaryValues = values + .Select(x => x.Value) + .ToList(); + MedianLocalSalary = salaryValues.Median(); AverageLocalSalary = salaryValues.Count > 0 ? salaryValues.Average() : 0; TotalSalaryCount = totalCount; + MinLocalSalary = salaryValues.Count > 0 ? salaryValues.Min() : null; + MaxLocalSalary = salaryValues.Count > 0 ? salaryValues.Max() : null; MedianLocalSalaryByGrade = new Dictionary(); @@ -51,6 +56,10 @@ public StatDataCacheItemSalaryData( public double AverageLocalSalary { get; init; } + public double? MinLocalSalary { get; init; } + + public double? MaxLocalSalary { get; init; } + public Dictionary MedianLocalSalaryByGrade { get; init; } = new (); public int TotalSalaryCount { get; init; } diff --git a/src/Infrastructure/Salaries/SalariesForChartQuery.cs b/src/Infrastructure/Salaries/SalariesForChartQuery.cs index 261b24b3..39016b5a 100644 --- a/src/Infrastructure/Salaries/SalariesForChartQuery.cs +++ b/src/Infrastructure/Salaries/SalariesForChartQuery.cs @@ -12,6 +12,8 @@ namespace Infrastructure.Salaries; public record SalariesForChartQuery { + public const int MonthsToShow = 18; + public DateQuarter CurrentQuarter { get; } public DeveloperGrade? Grade { get; } @@ -69,7 +71,7 @@ public SalariesForChartQuery( : this( context, request, - now.AddMonths(-18), + now.AddMonths(-MonthsToShow), now) { } diff --git a/src/Web.Api/Features/BackgroundJobs/SalarySubscriptionData.cs b/src/Web.Api/Features/BackgroundJobs/SalarySubscriptionData.cs new file mode 100644 index 00000000..2df65788 --- /dev/null +++ b/src/Web.Api/Features/BackgroundJobs/SalarySubscriptionData.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Domain.Entities.Salaries; +using Domain.Entities.StatData; +using Domain.Extensions; +using Infrastructure.Database; +using Infrastructure.Salaries; +using Microsoft.EntityFrameworkCore; +using Web.Api.Features.Telegram.ProcessMessage.UserCommands; + +namespace Web.Api.Features.BackgroundJobs; + +public record SalarySubscriptionData +{ + private readonly StatDataChangeSubscription _subscription; + private readonly DatabaseContext _context; + private readonly SalariesForChartQuery _salariesForChartQuery; + + public TelegramBotUserCommandParameters FilterData { get; } + + public List LastCacheItems { get; private set; } + + public StatDataChangeSubscriptionRecord LastCacheItemOrNull { get; private set; } + + public List Salaries { get; private set; } + + public int TotalSalaryCount { get; private set; } + + public SalarySubscriptionData( + List allProfessions, + StatDataChangeSubscription subscription, + DatabaseContext context, + DateTimeOffset now) + { + _subscription = subscription; + _context = context; + + FilterData = new TelegramBotUserCommandParameters( + allProfessions + .When( + subscription.ProfessionIds != null && + subscription.ProfessionIds.Count > 0, + x => subscription.ProfessionIds.Contains(x.Id)) + .ToList()); + + _salariesForChartQuery = new SalariesForChartQuery( + _context, + FilterData, + now); + } + + public async Task Initialize( + CancellationToken cancellationToken) + { + LastCacheItems = await _context.StatDataChangeSubscriptionRecords + .AsNoTracking() + .Where(x => x.SubscriptionId == _subscription.Id) + .OrderByDescending(x => x.CreatedAt) + .Take(3) + .ToListAsync(cancellationToken); + + LastCacheItemOrNull = LastCacheItems.FirstOrDefault(); + + TotalSalaryCount = await _salariesForChartQuery.CountAsync(cancellationToken); + Salaries = await _salariesForChartQuery + .ToQueryable(CompanyType.Local) + .Where(x => x.Grade.HasValue) + .Select(x => new SalaryGraveValue + { + Grade = x.Grade.Value, + Value = x.Value, + }) + .ToListAsync(cancellationToken); + + return this; + } + + public StatDataCacheItemSalaryData GetStatDataCacheItemSalaryData() + { + return new StatDataCacheItemSalaryData( + Salaries, + TotalSalaryCount); + } +} \ No newline at end of file diff --git a/src/Web.Api/Features/BackgroundJobs/StatDataChangeSubscriptionCalculateJob.cs b/src/Web.Api/Features/BackgroundJobs/StatDataChangeSubscriptionCalculateJob.cs index a91d5009..40e38a32 100644 --- a/src/Web.Api/Features/BackgroundJobs/StatDataChangeSubscriptionCalculateJob.cs +++ b/src/Web.Api/Features/BackgroundJobs/StatDataChangeSubscriptionCalculateJob.cs @@ -64,65 +64,41 @@ public override async Task ExecuteAsync( } var allProfessions = await _professionsCacheService.GetProfessionsAsync(cancellationToken); - var currencies = await _currencyService.GetCurrenciesAsync( + var usdCurrency = (await _currencyService.GetCurrenciesAsync( [Currency.USD], - cancellationToken); + cancellationToken)) + .FirstOrDefault(); var listOfDataToBeSent = new List<(StatDataChangeSubscriptionRecord Item, TelegramBotReplyData Data)>(); var now = DateTimeOffset.Now; foreach (var subscription in subscriptions) { - List lastCacheItems = await _context.StatDataChangeSubscriptionRecords - .AsNoTracking() - .Where(x => x.SubscriptionId == subscription.Id) - .OrderByDescending(x => x.CreatedAt) - .Take(3) - .ToListAsync(cancellationToken); - - var lastCacheItemOrNull = lastCacheItems.FirstOrDefault(); - - var filterData = new TelegramBotUserCommandParameters( - allProfessions - .When( - subscription.ProfessionIds != null && - subscription.ProfessionIds.Count > 0, - x => subscription.ProfessionIds.Contains(x.Id)) - .ToList()); - - var salariesQuery = new SalariesForChartQuery( - _context, - filterData, - now); - - var totalCount = await salariesQuery.CountAsync(cancellationToken); - var salaries = await salariesQuery - .ToQueryable(CompanyType.Local) - .Where(x => x.Grade.HasValue) - .Select(x => new SalaryGraveValue - { - Grade = x.Grade.Value, - Value = x.Value, - }) - .ToListAsync(cancellationToken); - - var salariesChartPageLink = new ChartPageLink(_global, filterData) - .AddQueryParam("utm_source", subscription.TelegramChatId.ToString()) - .AddQueryParam("utm_campaign", "telegram-regular-stats-update"); - - if (salaries.Count == 0) + var subscriptionData = await new SalarySubscriptionData( + allProfessions: allProfessions, + subscription: subscription, + context: _context, + now: now) + .Initialize(cancellationToken); + + var lastCacheItemOrNull = subscriptionData.LastCacheItemOrNull; + if (subscriptionData.Salaries.Count == 0) { - // TODO log + Logger.LogInformation( + "No salaries found for subscription {SubscriptionId} ({Name}). Skipping notification.", + subscription.Id, + subscription.Name); + continue; } - var professions = filterData.GetProfessionsTitleOrNull(); + var professions = subscriptionData.FilterData.GetProfessionsTitleOrNull(); var textMessageToBeSent = $"Зарплаты {professions ?? "специалистов IT в Казахстане"} по грейдам на дату {now:yyyy-MM-dd}:\n\n"; var hasAnyDifference = lastCacheItemOrNull == null; foreach (var gradeGroup in StatDataCacheItemSalaryData.GradeGroupsForRegularStats) { - var median = salaries + var median = subscriptionData.Salaries .Where(x => x.Grade.GetGroupNameOrNull() == gradeGroup) .Select(x => x.Value) .Median(); @@ -142,29 +118,29 @@ public override async Task ExecuteAsync( if (diffInPercent is > 0 or < 0) { - hasAnyDifference = hasAnyDifference || true; + hasAnyDifference = true; var sign = diffInPercent > 0 ? "🔼 " : "🔻 "; line += $"{sign}{diffInPercent.ToString("N0", CultureInfo.InvariantCulture)}%. "; } } - foreach (var currencyContent in currencies) + if (usdCurrency != null) { - var currencyValue = (median / currencyContent.Value).ToString("N0", CultureInfo.InvariantCulture); - line += $"(~{currencyValue}{currencyContent.CurrencyString}) "; + var currencyValue = (median / usdCurrency.Value).ToString("N0", CultureInfo.InvariantCulture); + line += $"(~{currencyValue}{usdCurrency.CurrencyString}) "; } line = line.Trim(); textMessageToBeSent += line + "\n"; } - var calculatedBasedOnLine = $"Рассчитано на основе {totalCount} анкет(ы)"; + var calculatedBasedOnLine = $"Рассчитано на основе {subscriptionData.TotalSalaryCount} анкет(ы)"; if (lastCacheItemOrNull is not null && - totalCount > lastCacheItemOrNull.Data.TotalSalaryCount) + subscriptionData.TotalSalaryCount > lastCacheItemOrNull.Data.TotalSalaryCount) { - calculatedBasedOnLine += $" (+{totalCount - lastCacheItemOrNull.Data.TotalSalaryCount})"; + calculatedBasedOnLine += $" (+{subscriptionData.TotalSalaryCount - lastCacheItemOrNull.Data.TotalSalaryCount})"; } if (!hasAnyDifference && @@ -178,6 +154,10 @@ public override async Task ExecuteAsync( continue; } + var salariesChartPageLink = GetChartPageLink( + subscription, + subscriptionData.FilterData); + textMessageToBeSent += $"\n{calculatedBasedOnLine}" + $"\nРазные графики и фильтры доступны по ссылке {SalariesPageUrl}" + @@ -192,10 +172,8 @@ public override async Task ExecuteAsync( var subscriptionRecord = new StatDataChangeSubscriptionRecord( subscription, - lastCacheItemOrNull, - new StatDataCacheItemSalaryData( - salaries, - totalCount)); + subscriptionData.LastCacheItemOrNull, + subscriptionData.GetStatDataCacheItemSalaryData()); _context.Add(subscriptionRecord); listOfDataToBeSent.Add((subscriptionRecord, dataTobeSent)); @@ -254,6 +232,15 @@ public override async Task ExecuteAsync( } } + private ChartPageLink GetChartPageLink( + StatDataChangeSubscription subscription, + TelegramBotUserCommandParameters filterData) + { + return new ChartPageLink(_global, filterData) + .AddQueryParam("utm_source", subscription.TelegramChatId.ToString()) + .AddQueryParam("utm_campaign", "telegram-regular-stats-update"); + } + private async Task TrySendTelegramMessageAsync( StatDataChangeSubscriptionRecord subscriptionRecord, TelegramBotReplyData tgData, diff --git a/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReportRoleSalaryData.cs b/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReportRoleSalaryData.cs index 6fbd4946..c784369f 100644 --- a/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReportRoleSalaryData.cs +++ b/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReportRoleSalaryData.cs @@ -20,9 +20,9 @@ public OpenAiBodyReportRoleSalaryData( public double Median { get; } - public double Min { get; } + public double? Min { get; } - public double Max { get; } + public double? Max { get; } public int Count { get; } } \ No newline at end of file From d132dddcf69426961b259cac93b99885eda9e48d Mon Sep 17 00:00:00 2001 From: "maxim.gorbatyuk" Date: Sat, 3 May 2025 11:58:41 +0500 Subject: [PATCH 6/8] Added report and analysis --- src/.env.example | 3 +- .../Entities/StatData/SalaryBaseData.cs | 15 +++++ .../Entities/StatData/SalaryGraveValue.cs | 10 --- .../StatData/StatDataCacheItemSalaryData.cs | 2 +- .../ValueObjects/ISalariesChartQueryParams.cs | 4 +- .../Currencies/Contracts/ICurrencyService.cs | 2 +- .../Currencies/CurrencyService.cs | 2 +- src/Infrastructure/Salaries/ChartPageLink.cs | 4 +- .../Salaries/SalariesForChartQuery.cs | 2 +- .../Services/OpenAi/IOpenAiService.cs | 9 +++ .../Services/OpenAi/OpenAiService.cs | 63 +++++++++++++++++++ src/TestUtils/Mocks/CurrenciesServiceFake.cs | 2 +- ...rsTelegramBotUserCommandParametersTests.cs | 8 +-- .../BackgroundJobs/SalarySubscriptionData.cs | 23 ++++++- .../StatDataChangeSubscriptionCalculateJob.cs | 15 +++-- .../GetSurveyHistoricalChartHandler.cs | 4 +- .../Historical/HistoricalChartsController.cs | 4 +- .../Salaries/Admin/SalariesAdminQuery.cs | 2 +- .../GetSalaries/GetSalariesPaginatedQuery.cs | 6 +- .../Charts/SalariesChartQueryParams.cs | 6 +- .../Salaries/SalariesChartQueryParamsBase.cs | 6 +- .../Features/Salaries/SalariesController.cs | 4 +- .../GetChartSharePageHandler.cs | 4 +- .../GetOpenAiReport/GetOpenAiReportHandler.cs | 52 +++++++++++++++ .../GetOpenAiReport/GetOpenAiReportQuery.cs | 16 +++++ .../GetOpenAiReportAnalysisHandler.cs | 59 +++++++++++++++++ .../GetOpenAiReportAnalysisQuery.cs | 15 +++++ .../GetOpenAiReportAnalysisResponse.cs | 22 +++++++ .../TelegramSubscriptionsController.cs | 23 +++++++ .../ProcessTelegramMessageHandler.cs | 12 ++-- .../TelegramBotUserCommandParameters.cs | 6 +- .../OpenAiBodyReport.cs | 33 +++++++--- .../OpenAiBodyReportRole.cs | 45 ++++++++++++- .../OpenAiBodyReportRoleHistoricalDataItem.cs | 26 +++++++- .../OpenAiBodyReportRoleSalaryData.cs | 37 +++++++---- src/Web.Api/Properties/launchSettings.json | 3 +- src/Web.Api/Setup/ServiceRegistration.cs | 25 ++++---- src/Web.Api/Views/ChartSharePageViewModel.cs | 4 +- src/Web.Api/appsettings.json | 6 +- 39 files changed, 482 insertions(+), 102 deletions(-) create mode 100644 src/Domain/Entities/StatData/SalaryBaseData.cs delete mode 100644 src/Domain/Entities/StatData/SalaryGraveValue.cs create mode 100644 src/Infrastructure/Services/OpenAi/IOpenAiService.cs create mode 100644 src/Infrastructure/Services/OpenAi/OpenAiService.cs create mode 100644 src/Web.Api/Features/Subscribtions/GetOpenAiReport/GetOpenAiReportHandler.cs create mode 100644 src/Web.Api/Features/Subscribtions/GetOpenAiReport/GetOpenAiReportQuery.cs create mode 100644 src/Web.Api/Features/Subscribtions/GetOpenAiReportAnalysis/GetOpenAiReportAnalysisHandler.cs create mode 100644 src/Web.Api/Features/Subscribtions/GetOpenAiReportAnalysis/GetOpenAiReportAnalysisQuery.cs create mode 100644 src/Web.Api/Features/Subscribtions/GetOpenAiReportAnalysis/GetOpenAiReportAnalysisResponse.cs diff --git a/src/.env.example b/src/.env.example index 090fb133..56bbffff 100644 --- a/src/.env.example +++ b/src/.env.example @@ -1,3 +1,4 @@ SendGridApiKey='' IdentityServer_Audience='' -Telegram__BotToken='' \ No newline at end of file +Telegram__BotToken='' +OpenAiApi__Secret='' \ No newline at end of file diff --git a/src/Domain/Entities/StatData/SalaryBaseData.cs b/src/Domain/Entities/StatData/SalaryBaseData.cs new file mode 100644 index 00000000..f5fd0ad3 --- /dev/null +++ b/src/Domain/Entities/StatData/SalaryBaseData.cs @@ -0,0 +1,15 @@ +using System; +using Domain.Entities.Enums; + +namespace Domain.Entities.StatData; + +public record SalaryBaseData +{ + public long? ProfessionId { get; init; } + + public DeveloperGrade Grade { get; init; } + + public double Value { get; init; } + + public DateTimeOffset CreatedAt { get; init; } +} \ No newline at end of file diff --git a/src/Domain/Entities/StatData/SalaryGraveValue.cs b/src/Domain/Entities/StatData/SalaryGraveValue.cs deleted file mode 100644 index 13ca9a4a..00000000 --- a/src/Domain/Entities/StatData/SalaryGraveValue.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Domain.Entities.Enums; - -namespace Domain.Entities.StatData; - -public record SalaryGraveValue -{ - public DeveloperGrade Grade { get; init; } - - public double Value { get; init; } -} \ No newline at end of file diff --git a/src/Domain/Entities/StatData/StatDataCacheItemSalaryData.cs b/src/Domain/Entities/StatData/StatDataCacheItemSalaryData.cs index 6ebaea31..614df772 100644 --- a/src/Domain/Entities/StatData/StatDataCacheItemSalaryData.cs +++ b/src/Domain/Entities/StatData/StatDataCacheItemSalaryData.cs @@ -19,7 +19,7 @@ public StatDataCacheItemSalaryData() } public StatDataCacheItemSalaryData( - List values, + List values, int totalCount) { var salaryValues = values diff --git a/src/Domain/ValueObjects/ISalariesChartQueryParams.cs b/src/Domain/ValueObjects/ISalariesChartQueryParams.cs index 4385507b..4015f056 100644 --- a/src/Domain/ValueObjects/ISalariesChartQueryParams.cs +++ b/src/Domain/ValueObjects/ISalariesChartQueryParams.cs @@ -9,7 +9,7 @@ public interface ISalariesChartQueryParams { public DeveloperGrade? Grade { get; } - public List ProfessionsToInclude { get; } + public List SelectedProfessionIds { get; } public List Skills { get; } @@ -22,7 +22,7 @@ public interface ISalariesChartQueryParams public int? YearTo { get; } public bool HasAnyFilter => - Grade.HasValue || ProfessionsToInclude.Count > 0 || Cities.Count > 0; + Grade.HasValue || SelectedProfessionIds.Count > 0 || Cities.Count > 0; string GetKeyPostfix(); } \ No newline at end of file diff --git a/src/Infrastructure/Currencies/Contracts/ICurrencyService.cs b/src/Infrastructure/Currencies/Contracts/ICurrencyService.cs index 397fddf1..1670539e 100644 --- a/src/Infrastructure/Currencies/Contracts/ICurrencyService.cs +++ b/src/Infrastructure/Currencies/Contracts/ICurrencyService.cs @@ -4,7 +4,7 @@ namespace Infrastructure.Currencies.Contracts { public interface ICurrencyService { - Task GetCurrencyAsync( + Task GetCurrencyOrNullAsync( Currency currency, CancellationToken cancellationToken); diff --git a/src/Infrastructure/Currencies/CurrencyService.cs b/src/Infrastructure/Currencies/CurrencyService.cs index ce7ebf09..456fd50e 100644 --- a/src/Infrastructure/Currencies/CurrencyService.cs +++ b/src/Infrastructure/Currencies/CurrencyService.cs @@ -29,7 +29,7 @@ public CurrencyService( _logger = logger; } - public async Task GetCurrencyAsync( + public async Task GetCurrencyOrNullAsync( Currency currency, CancellationToken cancellationToken) { diff --git a/src/Infrastructure/Salaries/ChartPageLink.cs b/src/Infrastructure/Salaries/ChartPageLink.cs index 1a7993c4..28df1d9a 100644 --- a/src/Infrastructure/Salaries/ChartPageLink.cs +++ b/src/Infrastructure/Salaries/ChartPageLink.cs @@ -45,9 +45,9 @@ public override string ToString() if (_requestOrNull != null || _additionalQueryParams.Count > 0) { string queryParams = null; - if (_requestOrNull?.ProfessionsToInclude.Count > 0) + if (_requestOrNull?.SelectedProfessionIds.Count > 0) { - queryParams += $"?profsInclude={string.Join(",", _requestOrNull.ProfessionsToInclude)}"; + queryParams += $"?profsInclude={string.Join(",", _requestOrNull.SelectedProfessionIds)}"; } if (_requestOrNull?.Grade != null) diff --git a/src/Infrastructure/Salaries/SalariesForChartQuery.cs b/src/Infrastructure/Salaries/SalariesForChartQuery.cs index 39016b5a..9b8c5e1e 100644 --- a/src/Infrastructure/Salaries/SalariesForChartQuery.cs +++ b/src/Infrastructure/Salaries/SalariesForChartQuery.cs @@ -84,7 +84,7 @@ public SalariesForChartQuery( : this( context, request.Grade, - request.ProfessionsToInclude, + request.SelectedProfessionIds, request.Skills, request.Cities, from, diff --git a/src/Infrastructure/Services/OpenAi/IOpenAiService.cs b/src/Infrastructure/Services/OpenAi/IOpenAiService.cs new file mode 100644 index 00000000..1044cd7b --- /dev/null +++ b/src/Infrastructure/Services/OpenAi/IOpenAiService.cs @@ -0,0 +1,9 @@ +namespace Infrastructure.Services.OpenAi; + +public interface IOpenAiService +{ + string GetBearer(); + + Task GetAnalysisAsync( + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Infrastructure/Services/OpenAi/OpenAiService.cs b/src/Infrastructure/Services/OpenAi/OpenAiService.cs new file mode 100644 index 00000000..94e4a2e3 --- /dev/null +++ b/src/Infrastructure/Services/OpenAi/OpenAiService.cs @@ -0,0 +1,63 @@ +using System.Net.Http.Headers; +using Infrastructure.Jwt; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Infrastructure.Services.OpenAi; + +public class OpenAiService : IOpenAiService +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public OpenAiService( + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + ILogger logger) + { + _httpClientFactory = httpClientFactory; + _configuration = configuration; + _logger = logger; + } + + public string GetBearer() + { + var secret = _configuration["OpenAiApi:Secret"]; + return new TechinterviewJwtTokenGenerator(secret).ToString(); + } + + public async Task GetAnalysisAsync( + CancellationToken cancellationToken = default) + { + var apiUrl = _configuration["OpenAiApi:Url"]; + if (string.IsNullOrEmpty(apiUrl)) + { + throw new InvalidOperationException("OpenAI API url is not set"); + } + + var responseContent = string.Empty; + try + { + using var client = _httpClientFactory.CreateClient(); + + client.BaseAddress = new Uri(apiUrl); + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", GetBearer()); + + responseContent = await client.GetStringAsync(string.Empty, cancellationToken); + return responseContent; + } + catch (Exception e) + { + _logger.LogError( + e, + "Error while getting OpenAI response from {Url}. Message {Message}. Response {Response}", + apiUrl, + e.Message, + responseContent); + + return string.Empty; + } + } +} \ No newline at end of file diff --git a/src/TestUtils/Mocks/CurrenciesServiceFake.cs b/src/TestUtils/Mocks/CurrenciesServiceFake.cs index 2b8e0efd..51d191f8 100644 --- a/src/TestUtils/Mocks/CurrenciesServiceFake.cs +++ b/src/TestUtils/Mocks/CurrenciesServiceFake.cs @@ -38,7 +38,7 @@ public CurrenciesServiceFake( _currencies = currencies; } - public Task GetCurrencyAsync( + public Task GetCurrencyOrNullAsync( Currency currency, CancellationToken cancellationToken) { diff --git a/src/Web.Api.Tests/Features/Telegram/ProcessMessage/UserCommands/QaAndTestersTelegramBotUserCommandParametersTests.cs b/src/Web.Api.Tests/Features/Telegram/ProcessMessage/UserCommands/QaAndTestersTelegramBotUserCommandParametersTests.cs index a1e6b3b6..90af0ad2 100644 --- a/src/Web.Api.Tests/Features/Telegram/ProcessMessage/UserCommands/QaAndTestersTelegramBotUserCommandParametersTests.cs +++ b/src/Web.Api.Tests/Features/Telegram/ProcessMessage/UserCommands/QaAndTestersTelegramBotUserCommandParametersTests.cs @@ -21,9 +21,9 @@ public void Ctor_TestersProfessions_Ok() var target = new QaAndTestersTelegramBotUserCommandParameters(professions); - Assert.Equal(3, target.ProfessionsToInclude.Count); - Assert.Equal(professions[0].Id, target.ProfessionsToInclude[0]); - Assert.Equal(professions[1].Id, target.ProfessionsToInclude[1]); - Assert.Equal(professions[2].Id, target.ProfessionsToInclude[2]); + Assert.Equal(3, target.SelectedProfessionIds.Count); + Assert.Equal(professions[0].Id, target.SelectedProfessionIds[0]); + Assert.Equal(professions[1].Id, target.SelectedProfessionIds[1]); + Assert.Equal(professions[2].Id, target.SelectedProfessionIds[2]); } } \ No newline at end of file diff --git a/src/Web.Api/Features/BackgroundJobs/SalarySubscriptionData.cs b/src/Web.Api/Features/BackgroundJobs/SalarySubscriptionData.cs index 2df65788..cb719b59 100644 --- a/src/Web.Api/Features/BackgroundJobs/SalarySubscriptionData.cs +++ b/src/Web.Api/Features/BackgroundJobs/SalarySubscriptionData.cs @@ -19,13 +19,15 @@ public record SalarySubscriptionData private readonly DatabaseContext _context; private readonly SalariesForChartQuery _salariesForChartQuery; + private bool _isInitianlized; + public TelegramBotUserCommandParameters FilterData { get; } public List LastCacheItems { get; private set; } public StatDataChangeSubscriptionRecord LastCacheItemOrNull { get; private set; } - public List Salaries { get; private set; } + public List Salaries { get; private set; } public int TotalSalaryCount { get; private set; } @@ -37,6 +39,7 @@ public SalarySubscriptionData( { _subscription = subscription; _context = context; + _isInitianlized = false; FilterData = new TelegramBotUserCommandParameters( allProfessions @@ -52,7 +55,7 @@ public SalarySubscriptionData( now); } - public async Task Initialize( + public async Task InitializeAsync( CancellationToken cancellationToken) { LastCacheItems = await _context.StatDataChangeSubscriptionRecords @@ -68,16 +71,30 @@ public async Task Initialize( Salaries = await _salariesForChartQuery .ToQueryable(CompanyType.Local) .Where(x => x.Grade.HasValue) - .Select(x => new SalaryGraveValue + .Select(x => new SalaryBaseData { + ProfessionId = x.ProfessionId, Grade = x.Grade.Value, Value = x.Value, + CreatedAt = x.CreatedAt, }) .ToListAsync(cancellationToken); + _isInitianlized = true; return this; } + public SalarySubscriptionData IsInitializedOrFail() + { + if (_isInitianlized) + { + return this; + } + + throw new InvalidOperationException( + $"SalarySubscriptionData is not initialized. Call {nameof(InitializeAsync)}() method first."); + } + public StatDataCacheItemSalaryData GetStatDataCacheItemSalaryData() { return new StatDataCacheItemSalaryData( diff --git a/src/Web.Api/Features/BackgroundJobs/StatDataChangeSubscriptionCalculateJob.cs b/src/Web.Api/Features/BackgroundJobs/StatDataChangeSubscriptionCalculateJob.cs index 40e38a32..d28d1d40 100644 --- a/src/Web.Api/Features/BackgroundJobs/StatDataChangeSubscriptionCalculateJob.cs +++ b/src/Web.Api/Features/BackgroundJobs/StatDataChangeSubscriptionCalculateJob.cs @@ -64,10 +64,9 @@ public override async Task ExecuteAsync( } var allProfessions = await _professionsCacheService.GetProfessionsAsync(cancellationToken); - var usdCurrency = (await _currencyService.GetCurrenciesAsync( - [Currency.USD], - cancellationToken)) - .FirstOrDefault(); + var usdCurrencyOrNull = await _currencyService.GetCurrencyOrNullAsync( + Currency.USD, + cancellationToken); var listOfDataToBeSent = new List<(StatDataChangeSubscriptionRecord Item, TelegramBotReplyData Data)>(); var now = DateTimeOffset.Now; @@ -79,7 +78,7 @@ public override async Task ExecuteAsync( subscription: subscription, context: _context, now: now) - .Initialize(cancellationToken); + .InitializeAsync(cancellationToken); var lastCacheItemOrNull = subscriptionData.LastCacheItemOrNull; if (subscriptionData.Salaries.Count == 0) @@ -125,10 +124,10 @@ public override async Task ExecuteAsync( } } - if (usdCurrency != null) + if (usdCurrencyOrNull != null) { - var currencyValue = (median / usdCurrency.Value).ToString("N0", CultureInfo.InvariantCulture); - line += $"(~{currencyValue}{usdCurrency.CurrencyString}) "; + var currencyValue = (median / usdCurrencyOrNull.Value).ToString("N0", CultureInfo.InvariantCulture); + line += $"(~{currencyValue}{usdCurrencyOrNull.CurrencyString}) "; } line = line.Trim(); diff --git a/src/Web.Api/Features/Historical/GetSurveyHistoricalChart/GetSurveyHistoricalChartHandler.cs b/src/Web.Api/Features/Historical/GetSurveyHistoricalChart/GetSurveyHistoricalChartHandler.cs index 20ed02c3..66fcfb00 100644 --- a/src/Web.Api/Features/Historical/GetSurveyHistoricalChart/GetSurveyHistoricalChartHandler.cs +++ b/src/Web.Api/Features/Historical/GetSurveyHistoricalChart/GetSurveyHistoricalChartHandler.cs @@ -101,11 +101,11 @@ public async Task Handle( x.LastSalaryOrNull.SkillId != null && request.Skills.Contains(x.LastSalaryOrNull.SkillId.Value)) .When( - request.ProfessionsToInclude.Count > 0, + request.SelectedProfessionIds.Count > 0, x => x.LastSalaryOrNull != null && x.LastSalaryOrNull.ProfessionId != null && - request.ProfessionsToInclude.Contains(x.LastSalaryOrNull.ProfessionId.Value)) + request.SelectedProfessionIds.Contains(x.LastSalaryOrNull.ProfessionId.Value)) .Select(x => new SurveyDatabaseData { UsefulnessRating = x.UsefulnessRating, diff --git a/src/Web.Api/Features/Historical/HistoricalChartsController.cs b/src/Web.Api/Features/Historical/HistoricalChartsController.cs index f4396ff2..b1088b34 100644 --- a/src/Web.Api/Features/Historical/HistoricalChartsController.cs +++ b/src/Web.Api/Features/Historical/HistoricalChartsController.cs @@ -32,7 +32,7 @@ public Task GetSalariesHistoricalChart( From = request.From, To = request.To, Grade = request.Grade, - ProfessionsToInclude = new DeveloperProfessionsCollection(request.ProfessionsToInclude).ToList(), + SelectedProfessionIds = new DeveloperProfessionsCollection(request.SelectedProfessionIds).ToList(), Cities = request.Cities, Skills = request.Skills, SalarySourceTypes = request.SalarySourceTypes, @@ -53,7 +53,7 @@ public Task GetSurveyHistoricalChart( From = request.From, To = request.To, Grade = request.Grade, - ProfessionsToInclude = new DeveloperProfessionsCollection(request.ProfessionsToInclude).ToList(), + SelectedProfessionIds = new DeveloperProfessionsCollection(request.SelectedProfessionIds).ToList(), Cities = request.Cities, Skills = request.Skills, SalarySourceTypes = request.SalarySourceTypes, diff --git a/src/Web.Api/Features/Salaries/Admin/SalariesAdminQuery.cs b/src/Web.Api/Features/Salaries/Admin/SalariesAdminQuery.cs index 3a8c3072..64bd6e87 100644 --- a/src/Web.Api/Features/Salaries/Admin/SalariesAdminQuery.cs +++ b/src/Web.Api/Features/Salaries/Admin/SalariesAdminQuery.cs @@ -58,7 +58,7 @@ public SalariesAdminQuery ApplyFilters( { _query = _query .When(queryParams.Grade.HasValue, x => x.Grade == queryParams.Grade.Value) - .When(queryParams.ProfessionsToInclude.Count > 0, x => x.ProfessionId.HasValue && queryParams.ProfessionsToInclude.Contains(x.ProfessionId.Value)) + .When(queryParams.SelectedProfessionIds.Count > 0, x => x.ProfessionId.HasValue && queryParams.SelectedProfessionIds.Contains(x.ProfessionId.Value)) .When(queryParams.Cities.Count > 0, x => x.City.HasValue && queryParams.Cities.Contains(x.City.Value)) .When(queryParams.Skills.Count > 0, x => x.SkillId != null && queryParams.Skills.Contains(x.SkillId.Value)); diff --git a/src/Web.Api/Features/Salaries/GetSalaries/GetSalariesPaginatedQuery.cs b/src/Web.Api/Features/Salaries/GetSalaries/GetSalariesPaginatedQuery.cs index ac9c4e5f..4d304982 100644 --- a/src/Web.Api/Features/Salaries/GetSalaries/GetSalariesPaginatedQuery.cs +++ b/src/Web.Api/Features/Salaries/GetSalaries/GetSalariesPaginatedQuery.cs @@ -17,7 +17,7 @@ public record GetSalariesPaginatedQuery public DeveloperGrade? Grade { get; init; } [FromQuery(Name = "profsInclude")] - public List ProfessionsToInclude { get; init; } = new (); + public List SelectedProfessionIds { get; init; } = new (); [FromQuery(Name = "cities")] public List Cities { get; init; } = new (); @@ -35,12 +35,12 @@ public record GetSalariesPaginatedQuery public int? YearTo { get; init; } public bool HasAnyFilter => - Grade.HasValue || ProfessionsToInclude.Count > 0 || Cities.Count > 0; + Grade.HasValue || SelectedProfessionIds.Count > 0 || Cities.Count > 0; public string GetKeyPostfix() { var grade = Grade?.ToString() ?? "all"; - var professions = ProfessionsToInclude.Count == 0 ? "all" : string.Join("_", ProfessionsToInclude); + var professions = SelectedProfessionIds.Count == 0 ? "all" : string.Join("_", SelectedProfessionIds); return $"{grade}_{professions}"; } } \ No newline at end of file diff --git a/src/Web.Api/Features/Salaries/GetSalariesChart/Charts/SalariesChartQueryParams.cs b/src/Web.Api/Features/Salaries/GetSalariesChart/Charts/SalariesChartQueryParams.cs index 3392ed55..e9162082 100644 --- a/src/Web.Api/Features/Salaries/GetSalariesChart/Charts/SalariesChartQueryParams.cs +++ b/src/Web.Api/Features/Salaries/GetSalariesChart/Charts/SalariesChartQueryParams.cs @@ -15,7 +15,7 @@ public record SalariesChartQueryParams : ISalariesChartQueryParams public DeveloperGrade? Grade { get; init; } [FromQuery(Name = "profsInclude")] - public List ProfessionsToInclude { get; init; } = new (); + public List SelectedProfessionIds { get; init; } = new (); [FromQuery(Name = "skills")] public List Skills { get; init; } = new (); @@ -33,12 +33,12 @@ public record SalariesChartQueryParams : ISalariesChartQueryParams public int? YearTo { get; init; } public bool HasAnyFilter => - Grade.HasValue || ProfessionsToInclude.Count > 0 || Cities.Count > 0; + Grade.HasValue || SelectedProfessionIds.Count > 0 || Cities.Count > 0; public string GetKeyPostfix() { var grade = Grade?.ToString() ?? "all"; - var professions = ProfessionsToInclude.Count == 0 ? "all" : string.Join("_", ProfessionsToInclude); + var professions = SelectedProfessionIds.Count == 0 ? "all" : string.Join("_", SelectedProfessionIds); return $"{grade}_{professions}"; } } \ No newline at end of file diff --git a/src/Web.Api/Features/Salaries/SalariesChartQueryParamsBase.cs b/src/Web.Api/Features/Salaries/SalariesChartQueryParamsBase.cs index b83581fa..963f8a9e 100644 --- a/src/Web.Api/Features/Salaries/SalariesChartQueryParamsBase.cs +++ b/src/Web.Api/Features/Salaries/SalariesChartQueryParamsBase.cs @@ -15,7 +15,7 @@ public record SalariesChartQueryParamsBase : ISalariesChartQueryParams public DeveloperGrade? Grade { get; init; } [FromQuery(Name = "profsInclude")] - public List ProfessionsToInclude { get; init; } = new (); + public List SelectedProfessionIds { get; init; } = new (); [FromQuery(Name = "cities")] public List Cities { get; init; } = new (); @@ -33,12 +33,12 @@ public record SalariesChartQueryParamsBase : ISalariesChartQueryParams public int? YearTo { get; init; } public bool HasAnyFilter => - Grade.HasValue || ProfessionsToInclude.Count > 0 || Cities.Count > 0; + Grade.HasValue || SelectedProfessionIds.Count > 0 || Cities.Count > 0; public string GetKeyPostfix() { var grade = Grade?.ToString() ?? "all"; - var professions = ProfessionsToInclude.Count == 0 ? "all" : string.Join("_", ProfessionsToInclude); + var professions = SelectedProfessionIds.Count == 0 ? "all" : string.Join("_", SelectedProfessionIds); return $"{grade}_{professions}"; } } \ No newline at end of file diff --git a/src/Web.Api/Features/Salaries/SalariesController.cs b/src/Web.Api/Features/Salaries/SalariesController.cs index 7d9e3db6..1d5ab442 100644 --- a/src/Web.Api/Features/Salaries/SalariesController.cs +++ b/src/Web.Api/Features/Salaries/SalariesController.cs @@ -69,7 +69,7 @@ public Task GetChart( new GetSalariesChartQuery { Grade = request.Grade, - ProfessionsToInclude = new DeveloperProfessionsCollection(request.ProfessionsToInclude).ToList(), + SelectedProfessionIds = new DeveloperProfessionsCollection(request.SelectedProfessionIds).ToList(), Cities = request.Cities, Skills = request.Skills, SalarySourceTypes = request.SalarySourceTypes, @@ -90,7 +90,7 @@ public Task> AllForPublic( Page = request.Page, PageSize = request.PageSize, Grade = request.Grade, - ProfessionsToInclude = new DeveloperProfessionsCollection(request.ProfessionsToInclude).ToList(), + SelectedProfessionIds = new DeveloperProfessionsCollection(request.SelectedProfessionIds).ToList(), Cities = request.Cities, Skills = request.Skills, SalarySourceTypes = request.SalarySourceTypes, diff --git a/src/Web.Api/Features/SalariesChartShare/GetChartSharePage/GetChartSharePageHandler.cs b/src/Web.Api/Features/SalariesChartShare/GetChartSharePage/GetChartSharePageHandler.cs index 8ad1fe09..6dabeef3 100644 --- a/src/Web.Api/Features/SalariesChartShare/GetChartSharePage/GetChartSharePageHandler.cs +++ b/src/Web.Api/Features/SalariesChartShare/GetChartSharePage/GetChartSharePageHandler.cs @@ -38,12 +38,12 @@ public async Task Handle( GetChartSharePageQuery request, CancellationToken cancellationToken) { - var professionsToInclude = new DeveloperProfessionsCollection(request.ProfessionsToInclude).ToList(); + var professionsToInclude = new DeveloperProfessionsCollection(request.SelectedProfessionIds).ToList(); var chartResponse = await _mediator.Send( new GetSalariesChartQuery { Grade = request.Grade, - ProfessionsToInclude = professionsToInclude, + SelectedProfessionIds = professionsToInclude, Cities = request.Cities, }, cancellationToken); diff --git a/src/Web.Api/Features/Subscribtions/GetOpenAiReport/GetOpenAiReportHandler.cs b/src/Web.Api/Features/Subscribtions/GetOpenAiReport/GetOpenAiReportHandler.cs new file mode 100644 index 00000000..1ccc86f4 --- /dev/null +++ b/src/Web.Api/Features/Subscribtions/GetOpenAiReport/GetOpenAiReportHandler.cs @@ -0,0 +1,52 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Domain.Entities.Salaries; +using Domain.Validation.Exceptions; +using Infrastructure.Database; +using Infrastructure.Services.Professions; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Web.Api.Features.BackgroundJobs; +using Web.Api.Integrations.OpenAiAnalysisIntegration; + +namespace Web.Api.Features.Subscribtions.GetOpenAiReport; + +public record GetOpenAiReportHandler : IRequestHandler +{ + private readonly DatabaseContext _context; + private readonly IProfessionsCacheService _professionsCacheService; + + public GetOpenAiReportHandler( + DatabaseContext context, + IProfessionsCacheService professionsCacheService) + { + _context = context; + _professionsCacheService = professionsCacheService; + } + + public async Task Handle( + GetOpenAiReportQuery request, + CancellationToken cancellationToken) + { + var subscription = await _context + .StatDataChangeSubscriptions + .AsNoTracking() + .FirstOrDefaultAsync( + x => x.Id == request.SubscriptionId, + cancellationToken: cancellationToken) + ?? throw new NotFoundException($"Subscription {request.SubscriptionId} not found"); + + var allProfessions = await _professionsCacheService.GetProfessionsAsync(cancellationToken); + + var result = new OpenAiBodyReport( + new SalarySubscriptionData( + allProfessions, + subscription, + _context, + DateTimeOffset.UtcNow), + Currency.KZT); + + return result; + } +} \ No newline at end of file diff --git a/src/Web.Api/Features/Subscribtions/GetOpenAiReport/GetOpenAiReportQuery.cs b/src/Web.Api/Features/Subscribtions/GetOpenAiReport/GetOpenAiReportQuery.cs new file mode 100644 index 00000000..20c8b713 --- /dev/null +++ b/src/Web.Api/Features/Subscribtions/GetOpenAiReport/GetOpenAiReportQuery.cs @@ -0,0 +1,16 @@ +using System; +using MediatR; +using Web.Api.Integrations.OpenAiAnalysisIntegration; + +namespace Web.Api.Features.Subscribtions.GetOpenAiReport; + +public record GetOpenAiReportQuery : IRequest +{ + public GetOpenAiReportQuery( + Guid subscriptionId) + { + SubscriptionId = subscriptionId; + } + + public Guid SubscriptionId { get; } +} \ No newline at end of file diff --git a/src/Web.Api/Features/Subscribtions/GetOpenAiReportAnalysis/GetOpenAiReportAnalysisHandler.cs b/src/Web.Api/Features/Subscribtions/GetOpenAiReportAnalysis/GetOpenAiReportAnalysisHandler.cs new file mode 100644 index 00000000..32a1effa --- /dev/null +++ b/src/Web.Api/Features/Subscribtions/GetOpenAiReportAnalysis/GetOpenAiReportAnalysisHandler.cs @@ -0,0 +1,59 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Domain.Entities.Salaries; +using Domain.Validation.Exceptions; +using Infrastructure.Database; +using Infrastructure.Services.OpenAi; +using Infrastructure.Services.Professions; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Web.Api.Features.BackgroundJobs; +using Web.Api.Integrations.OpenAiAnalysisIntegration; + +namespace Web.Api.Features.Subscribtions.GetOpenAiReportAnalysis; + +public class GetOpenAiReportAnalysisHandler : IRequestHandler +{ + private readonly DatabaseContext _context; + private readonly IProfessionsCacheService _professionsCacheService; + private readonly IOpenAiService _openApiService; + + public GetOpenAiReportAnalysisHandler( + DatabaseContext context, + IProfessionsCacheService professionsCacheService, + IOpenAiService openApiService) + { + _context = context; + _professionsCacheService = professionsCacheService; + _openApiService = openApiService; + } + + public async Task Handle( + GetOpenAiReportAnalysisQuery request, + CancellationToken cancellationToken) + { + var subscription = await _context + .StatDataChangeSubscriptions + .AsNoTracking() + .FirstOrDefaultAsync( + x => x.Id == request.SubscriptionId, + cancellationToken: cancellationToken) + ?? throw new NotFoundException($"Subscription {request.SubscriptionId} not found"); + + var allProfessions = await _professionsCacheService.GetProfessionsAsync(cancellationToken); + + var report = new OpenAiBodyReport( + new SalarySubscriptionData( + allProfessions, + subscription, + _context, + DateTimeOffset.UtcNow), + Currency.KZT); + + return new GetOpenAiReportAnalysisResponse( + await _openApiService.GetAnalysisAsync(cancellationToken), + report, + _openApiService.GetBearer()); + } +} \ No newline at end of file diff --git a/src/Web.Api/Features/Subscribtions/GetOpenAiReportAnalysis/GetOpenAiReportAnalysisQuery.cs b/src/Web.Api/Features/Subscribtions/GetOpenAiReportAnalysis/GetOpenAiReportAnalysisQuery.cs new file mode 100644 index 00000000..886fc644 --- /dev/null +++ b/src/Web.Api/Features/Subscribtions/GetOpenAiReportAnalysis/GetOpenAiReportAnalysisQuery.cs @@ -0,0 +1,15 @@ +using System; +using MediatR; + +namespace Web.Api.Features.Subscribtions.GetOpenAiReportAnalysis; + +public record GetOpenAiReportAnalysisQuery : IRequest +{ + public GetOpenAiReportAnalysisQuery( + Guid subscriptionId) + { + SubscriptionId = subscriptionId; + } + + public Guid SubscriptionId { get; } +} \ No newline at end of file diff --git a/src/Web.Api/Features/Subscribtions/GetOpenAiReportAnalysis/GetOpenAiReportAnalysisResponse.cs b/src/Web.Api/Features/Subscribtions/GetOpenAiReportAnalysis/GetOpenAiReportAnalysisResponse.cs new file mode 100644 index 00000000..a14789cd --- /dev/null +++ b/src/Web.Api/Features/Subscribtions/GetOpenAiReportAnalysis/GetOpenAiReportAnalysisResponse.cs @@ -0,0 +1,22 @@ +using Web.Api.Integrations.OpenAiAnalysisIntegration; + +namespace Web.Api.Features.Subscribtions.GetOpenAiReportAnalysis; + +public record GetOpenAiReportAnalysisResponse +{ + public GetOpenAiReportAnalysisResponse( + string analysis, + OpenAiBodyReport report, + string bearer) + { + Analysis = analysis; + Report = report; + Bearer = bearer; + } + + public string Analysis { get; } + + public OpenAiBodyReport Report { get; } + + public string Bearer { get; } +} \ No newline at end of file diff --git a/src/Web.Api/Features/Subscribtions/TelegramSubscriptionsController.cs b/src/Web.Api/Features/Subscribtions/TelegramSubscriptionsController.cs index f1e7809e..c495b639 100644 --- a/src/Web.Api/Features/Subscribtions/TelegramSubscriptionsController.cs +++ b/src/Web.Api/Features/Subscribtions/TelegramSubscriptionsController.cs @@ -9,6 +9,7 @@ using Web.Api.Features.Subscribtions.CreateSubscription; using Web.Api.Features.Subscribtions.DeactivateSubscription; using Web.Api.Features.Subscribtions.DeleteSubscription; +using Web.Api.Features.Subscribtions.GetOpenAiReportAnalysis; using Web.Api.Features.Subscribtions.GetStatDataChangeSubscriptions; using Web.Api.Setup.Attributes; @@ -83,4 +84,26 @@ await _mediator.Send( return NoContent(); } + + [HttpGet("{id:guid}/open-ai-analysis")] + public async Task GetOpenAiAnalysis( + [FromRoute] Guid id, + CancellationToken cancellationToken) + { + return Ok( + await _mediator.Send( + new GetOpenAiReportAnalysisQuery(id), + cancellationToken)); + } + + [HttpGet("{id:guid}/open-ai-report")] + public async Task GetOpenAiReport( + [FromRoute] Guid id, + CancellationToken cancellationToken) + { + return Ok( + await _mediator.Send( + new ActivateStatDataChangeSubscriptionCommand(id), + cancellationToken)); + } } \ No newline at end of file diff --git a/src/Web.Api/Features/Telegram/ProcessMessage/ProcessTelegramMessageHandler.cs b/src/Web.Api/Features/Telegram/ProcessMessage/ProcessTelegramMessageHandler.cs index 95bb64c2..f400bf90 100644 --- a/src/Web.Api/Features/Telegram/ProcessMessage/ProcessTelegramMessageHandler.cs +++ b/src/Web.Api/Features/Telegram/ProcessMessage/ProcessTelegramMessageHandler.cs @@ -298,10 +298,12 @@ private async Task ReplyWithSalariesAsync( var salaries = await salariesQuery .ToQueryable(CompanyType.Local) .Where(x => x.Grade != null) - .Select(x => new SalaryGraveValue + .Select(x => new SalaryBaseData { + ProfessionId = x.ProfessionId, Grade = x.Grade.Value, Value = x.Value, + CreatedAt = x.CreatedAt, }) .ToListAsync(cancellationToken); @@ -317,17 +319,13 @@ private async Task ReplyWithSalariesAsync( string replyText; if (salaries.Count > 0) { - var currencyContentOrNull = await _currencyService.GetCurrencyAsync( + var currencyContentOrNull = await _currencyService.GetCurrencyOrNullAsync( Currency.USD, cancellationToken); - var gradeGroups = EnumHelper - .Values() - .Where(x => x is not(GradeGroup.Undefined or GradeGroup.Trainee)); - replyText = $"Зарплаты {professions ?? "специалистов IT в Казахстане"} по грейдам:\n"; - foreach (var gradeGroup in gradeGroups) + foreach (var gradeGroup in StatDataCacheItemSalaryData.GradeGroupsForRegularStats) { var median = salaries .Where(x => x.Grade.GetGroupNameOrNull() == gradeGroup) diff --git a/src/Web.Api/Features/Telegram/ProcessMessage/UserCommands/TelegramBotUserCommandParameters.cs b/src/Web.Api/Features/Telegram/ProcessMessage/UserCommands/TelegramBotUserCommandParameters.cs index 21842136..902038fb 100644 --- a/src/Web.Api/Features/Telegram/ProcessMessage/UserCommands/TelegramBotUserCommandParameters.cs +++ b/src/Web.Api/Features/Telegram/ProcessMessage/UserCommands/TelegramBotUserCommandParameters.cs @@ -31,14 +31,14 @@ public TelegramBotUserCommandParameters( { Grade = null; SelectedProfessions = professionsToInclude; - ProfessionsToInclude = professionsToInclude + SelectedProfessionIds = professionsToInclude .Select(x => x.Id) .ToList(); } public DeveloperGrade? Grade { get; } - public List ProfessionsToInclude { get; } = new (); + public List SelectedProfessionIds { get; } = new (); public List Skills { get; } = new (); @@ -55,7 +55,7 @@ public TelegramBotUserCommandParameters( public string GetKeyPostfix() { var grade = Grade?.ToString() ?? "all"; - var professions = ProfessionsToInclude.Count == 0 ? "all" : string.Join("_", ProfessionsToInclude); + var professions = SelectedProfessionIds.Count == 0 ? "all" : string.Join("_", SelectedProfessionIds); return $"{grade}_{professions}"; } diff --git a/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReport.cs b/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReport.cs index 06c84d94..be07b8de 100644 --- a/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReport.cs +++ b/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReport.cs @@ -1,22 +1,37 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Linq; using Domain.Entities.Salaries; -using Domain.Entities.StatData; +using Web.Api.Features.BackgroundJobs; namespace Web.Api.Integrations.OpenAiAnalysisIntegration; public record OpenAiBodyReport { - private readonly List _professions; - private readonly Currency _currency; + private readonly SalarySubscriptionData _subscriptionData; public OpenAiBodyReport( - List professions, - List subscriptionStatData, + SalarySubscriptionData subscriptionData, Currency currency) { - _professions = professions; - _currency = currency; - ReportMetadata = new OpenAiBodyReportMetadata(_currency); + _subscriptionData = subscriptionData.IsInitializedOrFail(); + ReportMetadata = new OpenAiBodyReportMetadata(currency); + Roles = new List(); + + var now = DateTimeOffset.UtcNow; + + foreach (var profession in subscriptionData.FilterData.SelectedProfessions) + { + var salariesForProfession = subscriptionData.Salaries + .Where(x => x.ProfessionId == profession.Id) + .ToList(); + + Roles.Add( + new OpenAiBodyReportRole( + profession, + salariesForProfession, + now)); + } } public OpenAiBodyReportMetadata ReportMetadata { get; } diff --git a/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReportRole.cs b/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReportRole.cs index eb677b09..964cb7e3 100644 --- a/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReportRole.cs +++ b/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReportRole.cs @@ -1,10 +1,51 @@ -namespace Web.Api.Integrations.OpenAiAnalysisIntegration; +using System; +using System.Collections.Generic; +using System.Linq; +using Domain.Entities.Salaries; +using Domain.Entities.StatData; + +namespace Web.Api.Integrations.OpenAiAnalysisIntegration; public record OpenAiBodyReportRole { + private const int HistoricalDataCount = 3; + + public OpenAiBodyReportRole( + Profession profession, + List salaries, + DateTimeOffset now) + { + RoleName = profession.Title; + CurrentSalary = new OpenAiBodyReportRoleSalaryData(salaries); + HistoricalData = new List(); + + for (var i = 0; i < HistoricalDataCount; i++) + { + var daysCount = (i + 1) * 7; + var salariesForDate = salaries + .Where(x => x.CreatedAt <= now.AddDays(-daysCount)) + .ToList(); + + if (salariesForDate.Count == 0) + { + break; + } + + var averageSalaryToCompare = i == 0 + ? CurrentSalary.Average + : HistoricalData[i - 1].Average; + + HistoricalData.Add( + new OpenAiBodyReportRoleHistoricalDataItem( + salariesForDate, + now.AddDays(-daysCount), + averageSalaryToCompare)); + } + } + public string RoleName { get; } public OpenAiBodyReportRoleSalaryData CurrentSalary { get; } - public OpenAiBodyReportRoleSalaryData HistoricalData { get; } + public List HistoricalData { get; } } \ No newline at end of file diff --git a/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReportRoleHistoricalDataItem.cs b/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReportRoleHistoricalDataItem.cs index 0a719cca..47a91700 100644 --- a/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReportRoleHistoricalDataItem.cs +++ b/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReportRoleHistoricalDataItem.cs @@ -1,7 +1,31 @@ -namespace Web.Api.Integrations.OpenAiAnalysisIntegration; +using System; +using System.Collections.Generic; +using System.Linq; +using Domain.Entities.StatData; + +namespace Web.Api.Integrations.OpenAiAnalysisIntegration; public record OpenAiBodyReportRoleHistoricalDataItem { + public OpenAiBodyReportRoleHistoricalDataItem( + List salariesForDate, + DateTimeOffset date, + double averageSalaryToCompare) + { + Date = date.ToString("yyyy-MM-dd"); + if (salariesForDate.Count == 0) + { + Average = 0; + PercentChange = 0; + return; + } + + Average = salariesForDate.Average(x => x.Value); + PercentChange = Math.Round( + (Average - averageSalaryToCompare) / averageSalaryToCompare, + 2); + } + public string Date { get; } public double Average { get; } diff --git a/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReportRoleSalaryData.cs b/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReportRoleSalaryData.cs index c784369f..fd145bfd 100644 --- a/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReportRoleSalaryData.cs +++ b/src/Web.Api/Integrations/OpenAiAnalysisIntegration/OpenAiBodyReportRoleSalaryData.cs @@ -1,19 +1,34 @@ -namespace Web.Api.Integrations.OpenAiAnalysisIntegration; +using System.Collections.Generic; +using System.Linq; +using Domain.Entities.StatData; +using Domain.Extensions; + +namespace Web.Api.Integrations.OpenAiAnalysisIntegration; public record OpenAiBodyReportRoleSalaryData { public OpenAiBodyReportRoleSalaryData( - double average, - double median, - double min, - double max, - int count) + List salaries) { - Average = average; - Median = median; - Min = min; - Max = max; - Count = count; + if (salaries.Count == 0) + { + Average = 0; + Median = 0; + Min = null; + Max = null; + Count = 0; + return; + } + + var salaryValues = salaries + .Select(x => x.Value) + .ToList(); + + Average = salaryValues.Average(); + Median = salaryValues.Median(); + Min = salaryValues.Min(); + Max = salaryValues.Max(); + Count = salaryValues.Count; } public double Average { get; } diff --git a/src/Web.Api/Properties/launchSettings.json b/src/Web.Api/Properties/launchSettings.json index be1c854f..f6914b72 100644 --- a/src/Web.Api/Properties/launchSettings.json +++ b/src/Web.Api/Properties/launchSettings.json @@ -16,7 +16,8 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "ConnectionStrings__Elasticsearch": "http://localhost:9200", - "GenerateStubData": "true" + "GenerateStubData": "true", + "OpenAiApi__Secret": "my-secret" }, "dotnetRunMessages": "true", "applicationUrl": "https://localhost:5001;http://localhost:5000" diff --git a/src/Web.Api/Setup/ServiceRegistration.cs b/src/Web.Api/Setup/ServiceRegistration.cs index 0c392c48..ea398cd6 100644 --- a/src/Web.Api/Setup/ServiceRegistration.cs +++ b/src/Web.Api/Setup/ServiceRegistration.cs @@ -9,6 +9,7 @@ using Infrastructure.Services.Global; using Infrastructure.Services.Html; using Infrastructure.Services.Http; +using Infrastructure.Services.OpenAi; using Infrastructure.Services.PDF; using Infrastructure.Services.PDF.Interviews; using Infrastructure.Services.Professions; @@ -26,18 +27,18 @@ public static IServiceCollection SetupAppServices( this IServiceCollection services, IConfiguration configuration) { - services.AddHttpContextAccessor(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - services.AddScoped(); - services.AddScoped(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); + services.AddHttpContextAccessor() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient(); // https://github.com/rdvojmoc/DinkToPdf/#dependency-injection // services.AddSingleton(); diff --git a/src/Web.Api/Views/ChartSharePageViewModel.cs b/src/Web.Api/Views/ChartSharePageViewModel.cs index 9ef78062..35335899 100644 --- a/src/Web.Api/Views/ChartSharePageViewModel.cs +++ b/src/Web.Api/Views/ChartSharePageViewModel.cs @@ -24,10 +24,10 @@ public ChartSharePageViewModel( if (requestParams.HasAnyFilter) { description = "Специалисты "; - if (requestParams.ProfessionsToInclude.Count > 0) + if (requestParams.SelectedProfessionIds.Count > 0) { description += string.Join(", ", professionsToInclude); - queryParams += $"?profsInclude={string.Join(",", requestParams.ProfessionsToInclude)}"; + queryParams += $"?profsInclude={string.Join(",", requestParams.SelectedProfessionIds)}"; } if (requestParams.Grade.HasValue) diff --git a/src/Web.Api/appsettings.json b/src/Web.Api/appsettings.json index 060683e2..16e1cb99 100644 --- a/src/Web.Api/appsettings.json +++ b/src/Web.Api/appsettings.json @@ -17,9 +17,13 @@ }, "Telegram": { "BotToken": "__TELEGRAM_BOT_KEY", - "Enable": "true", + "Enable": "true" }, "SendGridApiKey": "", + "OpenAiApi": { + "Url": "https://wf.belyaev.live/webhook/e69fa36a-adff-410c-8cd0-04ec833d849e", + "Secret": "my-secret" + }, "S3": { "BucketName": "my-awesome-bucket", "Region": "", From 2ecafbb1a8e09474d90135fcbbedf4f44330db2f Mon Sep 17 00:00:00 2001 From: "maxim.gorbatyuk" Date: Sat, 3 May 2025 12:02:16 +0500 Subject: [PATCH 7/8] Added open ai replacing --- .github/workflows/deploy.yml | 1 + src/Web.Api/appsettings.Production.json | 3 +++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1800360e..5ddcb1d2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -27,6 +27,7 @@ jobs: sed -i 's/"__S3_BUCKET_NAME",/"${{ vars.S3_BUCKET_NAME }}",/g' ${{env.PRODUCTION_SETTINGS_FILE}} sed -i 's/"__TELEGRAM_BOT_KEY",/"${{ secrets.TELEGRAM_BOT_KEY }}",/g' ${{env.PRODUCTION_SETTINGS_FILE}} sed -i 's/"__SENDGRID_API_KEY",/"${{ secrets.SENDGRID_API_KEY }}",/g' ${{env.PRODUCTION_SETTINGS_FILE}} + sed -i 's/"__OPEN_AI_SECRET",/"${{ secrets.OPEN_AI_SECRET }}",/g' ${{env.PRODUCTION_SETTINGS_FILE}} cd src docker login -u ${{ env.DO_TOKEN }} -p ${{ env.DO_TOKEN }} registry.digitalocean.com/techinterview docker build -f Dockerfile -t ${{ secrets.CR }}/backend:${{ github.sha }} . diff --git a/src/Web.Api/appsettings.Production.json b/src/Web.Api/appsettings.Production.json index cedcaeca..4224bfa9 100644 --- a/src/Web.Api/appsettings.Production.json +++ b/src/Web.Api/appsettings.Production.json @@ -20,6 +20,9 @@ "Enable": "true", "BotName": "@techinterview_salaries_bot" }, + "OpenAiApi": { + "Secret": "__OPEN_AI_SECRET" + }, "SendGridApiKey": "__SENDGRID_API_KEY", "BaseUrl": "https://techinterview.space", "ImageBaseUrl": "https://api.techinterview.space", From ed3af1e0598299290d97782b5ca79bd6cf9f6b2b Mon Sep 17 00:00:00 2001 From: "maxim.gorbatyuk" Date: Sat, 3 May 2025 12:09:34 +0500 Subject: [PATCH 8/8] Fixes --- src/Infrastructure/Salaries/SalariesForChartQuery.cs | 9 ++++----- .../Features/BackgroundJobs/SalarySubscriptionData.cs | 1 + .../Subscribtions/TelegramSubscriptionsController.cs | 3 ++- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/Infrastructure/Salaries/SalariesForChartQuery.cs b/src/Infrastructure/Salaries/SalariesForChartQuery.cs index 9b8c5e1e..20da2cae 100644 --- a/src/Infrastructure/Salaries/SalariesForChartQuery.cs +++ b/src/Infrastructure/Salaries/SalariesForChartQuery.cs @@ -18,8 +18,6 @@ public record SalariesForChartQuery public DeveloperGrade? Grade { get; } - public List ProfessionsToInclude { get; } - public List Skills { get; } public List Cities { get; } @@ -34,6 +32,7 @@ public record SalariesForChartQuery public int? YearTo { get; } + private readonly List _selectedProfessionIds; private readonly DatabaseContext _context; public SalariesForChartQuery( @@ -50,7 +49,7 @@ public SalariesForChartQuery( { _context = context; Grade = grade; - ProfessionsToInclude = professionsToInclude ?? new List(); + _selectedProfessionIds = professionsToInclude ?? new List(); Skills = skills ?? new List(); Cities = cities ?? new List(); @@ -181,8 +180,8 @@ private IQueryable BuildQuery( Skills.Count > 0, x => x.SkillId != null && Skills.Contains(x.SkillId.Value)) .When( - ProfessionsToInclude.Count > 0, - x => x.ProfessionId != null && ProfessionsToInclude.Contains(x.ProfessionId.Value)); + _selectedProfessionIds.Count > 0, + x => x.ProfessionId != null && _selectedProfessionIds.Contains(x.ProfessionId.Value)); return query; } diff --git a/src/Web.Api/Features/BackgroundJobs/SalarySubscriptionData.cs b/src/Web.Api/Features/BackgroundJobs/SalarySubscriptionData.cs index cb719b59..14258300 100644 --- a/src/Web.Api/Features/BackgroundJobs/SalarySubscriptionData.cs +++ b/src/Web.Api/Features/BackgroundJobs/SalarySubscriptionData.cs @@ -97,6 +97,7 @@ public SalarySubscriptionData IsInitializedOrFail() public StatDataCacheItemSalaryData GetStatDataCacheItemSalaryData() { + IsInitializedOrFail(); return new StatDataCacheItemSalaryData( Salaries, TotalSalaryCount); diff --git a/src/Web.Api/Features/Subscribtions/TelegramSubscriptionsController.cs b/src/Web.Api/Features/Subscribtions/TelegramSubscriptionsController.cs index c495b639..f66ce508 100644 --- a/src/Web.Api/Features/Subscribtions/TelegramSubscriptionsController.cs +++ b/src/Web.Api/Features/Subscribtions/TelegramSubscriptionsController.cs @@ -9,6 +9,7 @@ using Web.Api.Features.Subscribtions.CreateSubscription; using Web.Api.Features.Subscribtions.DeactivateSubscription; using Web.Api.Features.Subscribtions.DeleteSubscription; +using Web.Api.Features.Subscribtions.GetOpenAiReport; using Web.Api.Features.Subscribtions.GetOpenAiReportAnalysis; using Web.Api.Features.Subscribtions.GetStatDataChangeSubscriptions; using Web.Api.Setup.Attributes; @@ -103,7 +104,7 @@ public async Task GetOpenAiReport( { return Ok( await _mediator.Send( - new ActivateStatDataChangeSubscriptionCommand(id), + new GetOpenAiReportQuery(id), cancellationToken)); } } \ No newline at end of file