diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b71f66be..54b9f4e6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -30,6 +30,7 @@ jobs: sed -i 's/"__SENDGRID_API_KEY",/"${{ secrets.SENDGRID_API_KEY }}",/g' ${{env.PRODUCTION_SETTINGS_FILE}} sed -i 's/"__SENDGRID_WEBHOOK_SIGNATURE",/"${{ secrets.SENDGRID_WEBHOOK_SIGNATURE }}",/g' ${{env.PRODUCTION_SETTINGS_FILE}} sed -i 's/"__OPEN_AI_SECRET",/"${{ secrets.OPEN_AI_SECRET }}",/g' ${{env.PRODUCTION_SETTINGS_FILE}} + sed -i 's/"__OPENAI_API_KEY",/"${{ secrets.OPENAI_API_KEY }}",/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/.env.example b/src/.env.example index 4217efb9..d988b8ba 100644 --- a/src/.env.example +++ b/src/.env.example @@ -3,4 +3,5 @@ IdentityServer_Audience='' Telegram__SalariesBotToken='' Telegram__GithubProfileBotToken='' OpenAiApi__Secret='' -Telegram__GithubPATForLocalDevelopment='' \ No newline at end of file +Telegram__GithubPATForLocalDevelopment='' +OpenAI__ApiKey='' \ No newline at end of file diff --git a/src/Domain/Entities/Companies/Company.cs b/src/Domain/Entities/Companies/Company.cs index 48cef717..234cd5af 100644 --- a/src/Domain/Entities/Companies/Company.cs +++ b/src/Domain/Entities/Companies/Company.cs @@ -69,6 +69,16 @@ public List GetRelevantReviews() .ToList(); } + public bool HasRelevantReviews() + { + if (Reviews == null) + { + throw new InvalidOperationException("Reviews are not initialized."); + } + + return Reviews.Any(x => x.IsRelevant()); + } + public bool IsUserAllowedToLeaveReview( long userId) { diff --git a/src/Infrastructure/Services/OpenAi/Custom/CustomOpenAiService.cs b/src/Infrastructure/Services/OpenAi/Custom/CustomOpenAiService.cs new file mode 100644 index 00000000..816fa9c2 --- /dev/null +++ b/src/Infrastructure/Services/OpenAi/Custom/CustomOpenAiService.cs @@ -0,0 +1,84 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Infrastructure.Jwt; +using Infrastructure.Services.OpenAi.Custom.Models; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Infrastructure.Services.OpenAi.Custom; + +public class CustomOpenAiService : ICustomOpenAiService +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public CustomOpenAiService( + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + ILogger logger) + { + _httpClientFactory = httpClientFactory; + _configuration = configuration; + _logger = logger; + } + + public string GetBearer() + { + var secret = _configuration["OpenAiApiSecret"]; + return new TechinterviewJwtTokenGenerator(secret).ToString(); + } + + public async Task GetAnalysisAsync( + OpenAiBodyReport report, + CancellationToken cancellationToken = default) + { + var apiUrl = _configuration["OpenAiApiUrl"]; + 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); + var request = new HttpRequestMessage(HttpMethod.Post, apiUrl); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", GetBearer()); + request.Content = new StringContent( + JsonSerializer.Serialize(report), + Encoding.UTF8, + "application/json"); + + var response = await client.SendAsync(request, cancellationToken); + + responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + if (response.IsSuccessStatusCode) + { + return responseContent; + } + + _logger.LogError( + "Failed request to OpenAI {Url}. Status {Status}, Response {Response}", + apiUrl, + response.StatusCode, + responseContent); + + return string.Empty; + } + 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/Infrastructure/Services/OpenAi/Custom/ICustomOpenAiService.cs b/src/Infrastructure/Services/OpenAi/Custom/ICustomOpenAiService.cs new file mode 100644 index 00000000..f148ecce --- /dev/null +++ b/src/Infrastructure/Services/OpenAi/Custom/ICustomOpenAiService.cs @@ -0,0 +1,12 @@ +using Infrastructure.Services.OpenAi.Custom.Models; + +namespace Infrastructure.Services.OpenAi.Custom; + +public interface ICustomOpenAiService +{ + string GetBearer(); + + Task GetAnalysisAsync( + OpenAiBodyReport report, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Infrastructure/Services/OpenAi/Models/OpenAiBodyReport.cs b/src/Infrastructure/Services/OpenAi/Custom/Models/OpenAiBodyReport.cs similarity index 95% rename from src/Infrastructure/Services/OpenAi/Models/OpenAiBodyReport.cs rename to src/Infrastructure/Services/OpenAi/Custom/Models/OpenAiBodyReport.cs index aa444969..027db600 100644 --- a/src/Infrastructure/Services/OpenAi/Models/OpenAiBodyReport.cs +++ b/src/Infrastructure/Services/OpenAi/Custom/Models/OpenAiBodyReport.cs @@ -1,7 +1,7 @@ using Domain.Entities.Salaries; using Infrastructure.Salaries; -namespace Infrastructure.Services.OpenAi.Models; +namespace Infrastructure.Services.OpenAi.Custom.Models; public record OpenAiBodyReport { diff --git a/src/Infrastructure/Services/OpenAi/Models/OpenAiBodyReportMetadata.cs b/src/Infrastructure/Services/OpenAi/Custom/Models/OpenAiBodyReportMetadata.cs similarity index 87% rename from src/Infrastructure/Services/OpenAi/Models/OpenAiBodyReportMetadata.cs rename to src/Infrastructure/Services/OpenAi/Custom/Models/OpenAiBodyReportMetadata.cs index 987c94e8..c8a28bf8 100644 --- a/src/Infrastructure/Services/OpenAi/Models/OpenAiBodyReportMetadata.cs +++ b/src/Infrastructure/Services/OpenAi/Custom/Models/OpenAiBodyReportMetadata.cs @@ -1,6 +1,6 @@ using Domain.Entities.Salaries; -namespace Infrastructure.Services.OpenAi.Models; +namespace Infrastructure.Services.OpenAi.Custom.Models; public record OpenAiBodyReportMetadata { diff --git a/src/Infrastructure/Services/OpenAi/Models/OpenAiBodyReportRole.cs b/src/Infrastructure/Services/OpenAi/Custom/Models/OpenAiBodyReportRole.cs similarity index 96% rename from src/Infrastructure/Services/OpenAi/Models/OpenAiBodyReportRole.cs rename to src/Infrastructure/Services/OpenAi/Custom/Models/OpenAiBodyReportRole.cs index 10a5b8e9..f08b1ce5 100644 --- a/src/Infrastructure/Services/OpenAi/Models/OpenAiBodyReportRole.cs +++ b/src/Infrastructure/Services/OpenAi/Custom/Models/OpenAiBodyReportRole.cs @@ -1,7 +1,7 @@ using Domain.Entities.Salaries; using Domain.Entities.StatData; -namespace Infrastructure.Services.OpenAi.Models; +namespace Infrastructure.Services.OpenAi.Custom.Models; public record OpenAiBodyReportRole { diff --git a/src/Infrastructure/Services/OpenAi/Models/OpenAiBodyReportRoleHistoricalDataItem.cs b/src/Infrastructure/Services/OpenAi/Custom/Models/OpenAiBodyReportRoleHistoricalDataItem.cs similarity index 94% rename from src/Infrastructure/Services/OpenAi/Models/OpenAiBodyReportRoleHistoricalDataItem.cs rename to src/Infrastructure/Services/OpenAi/Custom/Models/OpenAiBodyReportRoleHistoricalDataItem.cs index 8644acdf..0fd61948 100644 --- a/src/Infrastructure/Services/OpenAi/Models/OpenAiBodyReportRoleHistoricalDataItem.cs +++ b/src/Infrastructure/Services/OpenAi/Custom/Models/OpenAiBodyReportRoleHistoricalDataItem.cs @@ -1,7 +1,7 @@ using Domain.Entities.StatData; using Domain.Extensions; -namespace Infrastructure.Services.OpenAi.Models; +namespace Infrastructure.Services.OpenAi.Custom.Models; public record OpenAiBodyReportRoleHistoricalDataItem { diff --git a/src/Infrastructure/Services/OpenAi/Models/OpenAiBodyReportRoleSalaryData.cs b/src/Infrastructure/Services/OpenAi/Custom/Models/OpenAiBodyReportRoleSalaryData.cs similarity index 93% rename from src/Infrastructure/Services/OpenAi/Models/OpenAiBodyReportRoleSalaryData.cs rename to src/Infrastructure/Services/OpenAi/Custom/Models/OpenAiBodyReportRoleSalaryData.cs index 4d0d706e..fc527a64 100644 --- a/src/Infrastructure/Services/OpenAi/Models/OpenAiBodyReportRoleSalaryData.cs +++ b/src/Infrastructure/Services/OpenAi/Custom/Models/OpenAiBodyReportRoleSalaryData.cs @@ -1,7 +1,7 @@ using Domain.Entities.StatData; using Domain.Extensions; -namespace Infrastructure.Services.OpenAi.Models; +namespace Infrastructure.Services.OpenAi.Custom.Models; public record OpenAiBodyReportRoleSalaryData { diff --git a/src/Infrastructure/Services/OpenAi/IOpenAiService.cs b/src/Infrastructure/Services/OpenAi/IOpenAiService.cs index b6b2da1d..6ecb0daa 100644 --- a/src/Infrastructure/Services/OpenAi/IOpenAiService.cs +++ b/src/Infrastructure/Services/OpenAi/IOpenAiService.cs @@ -1,12 +1,17 @@ -using Infrastructure.Services.OpenAi.Models; +using Domain.Entities.Companies; +using Infrastructure.Services.OpenAi.Models; namespace Infrastructure.Services.OpenAi; public interface IOpenAiService { - string GetBearer(); + Task AnalyzeCompanyAsync( + Company company, + string correlationId = null, + CancellationToken cancellationToken = default); - Task GetAnalysisAsync( - OpenAiBodyReport report, + Task AnalyzeChatAsync( + string input, + string correlationId = null, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/Infrastructure/Services/OpenAi/Models/ChatMessage.cs b/src/Infrastructure/Services/OpenAi/Models/ChatMessage.cs new file mode 100644 index 00000000..e6ef913d --- /dev/null +++ b/src/Infrastructure/Services/OpenAi/Models/ChatMessage.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace Infrastructure.Services.OpenAi.Models; + +public record ChatMessage +{ + public ChatMessage( + string role, + string content) + { + Role = role; + Content = content; + } + + public ChatMessage() + { + } + + [JsonPropertyName("role")] + public string Role { get; set; } // "user", "assistant", or "system" + + [JsonPropertyName("content")] + public string Content { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure/Services/OpenAi/Models/ChatRequest.cs b/src/Infrastructure/Services/OpenAi/Models/ChatRequest.cs new file mode 100644 index 00000000..94b31439 --- /dev/null +++ b/src/Infrastructure/Services/OpenAi/Models/ChatRequest.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Infrastructure.Services.OpenAi.Models; + +public record ChatRequest +{ + [JsonPropertyName("model")] + public string Model { get; set; } + + [JsonPropertyName("messages")] + public List Messages { get; set; } + + [JsonPropertyName("temperature")] + public double Temperature { get; set; } = 0.7; +} \ No newline at end of file diff --git a/src/Infrastructure/Services/OpenAi/Models/ChatResponse.cs b/src/Infrastructure/Services/OpenAi/Models/ChatResponse.cs new file mode 100644 index 00000000..986439c1 --- /dev/null +++ b/src/Infrastructure/Services/OpenAi/Models/ChatResponse.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Infrastructure.Services.OpenAi.Models; + +public record ChatResponse +{ + [JsonPropertyName("choices")] + public List Choices { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure/Services/OpenAi/Models/Choice.cs b/src/Infrastructure/Services/OpenAi/Models/Choice.cs new file mode 100644 index 00000000..86a53350 --- /dev/null +++ b/src/Infrastructure/Services/OpenAi/Models/Choice.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Infrastructure.Services.OpenAi.Models; + +public record Choice +{ + [JsonPropertyName("message")] + public ChatMessage Message { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure/Services/OpenAi/Models/CompanyAnalyzeRequest.cs b/src/Infrastructure/Services/OpenAi/Models/CompanyAnalyzeRequest.cs new file mode 100644 index 00000000..6d0d406a --- /dev/null +++ b/src/Infrastructure/Services/OpenAi/Models/CompanyAnalyzeRequest.cs @@ -0,0 +1,84 @@ +using Domain.Entities.Companies; + +namespace Infrastructure.Services.OpenAi.Models; + +internal record CompanyAnalyzeRequest +{ + public CompanyAnalyzeRequest( + Company company) + { + Name = company.Name; + Rating = company.Rating; + RatingHistoryRecords = company.RatingHistory? + .Select(history => new CompanyRatingHistoryItem(history)) + .ToList(); + + Reviews = company.Reviews + .Select(x => new CompanyReviewData(x)) + .ToList(); + } + + public double Rating { get; } + + public string Name { get; } + + public List RatingHistoryRecords { get; } + + public List Reviews { get; } +} + +internal record CompanyReviewData +{ + public CompanyReviewData( + CompanyReview review) + { + Pros = review.Pros; + Cons = review.Cons; + CultureAndValues = review.CultureAndValues; + CodeQuality = review.CodeQuality; + WorkLifeBalance = review.WorkLifeBalance; + Management = review.Management; + CompensationAndBenefits = review.CompensationAndBenefits; + CareerOpportunities = review.CareerOpportunities; + TotalRating = review.TotalRating; + LikesCount = review.LikesCount; + DislikesCount = review.DislikesCount; + TotalRating = review.TotalRating; + } + + public string Pros { get; } + + public string Cons { get; } + + public int CultureAndValues { get; } + + public int? CodeQuality { get; } + + public int WorkLifeBalance { get; } + + public int Management { get; } + + public int CompensationAndBenefits { get; } + + public int CareerOpportunities { get; } + + public double TotalRating { get; } + + public int LikesCount { get; } + + public int DislikesCount { get; } +} + +internal record CompanyRatingHistoryItem +{ + public CompanyRatingHistoryItem( + CompanyRatingHistory history) + { + CreatedAt = history.CreatedAt; + Rating = history.Rating; + } + + public DateTime CreatedAt { get; } + + public double Rating { get; } +} \ No newline at end of file diff --git a/src/Infrastructure/Services/OpenAi/Models/OpenAiChatResult.cs b/src/Infrastructure/Services/OpenAi/Models/OpenAiChatResult.cs new file mode 100644 index 00000000..e026d5a9 --- /dev/null +++ b/src/Infrastructure/Services/OpenAi/Models/OpenAiChatResult.cs @@ -0,0 +1,26 @@ +namespace Infrastructure.Services.OpenAi.Models; + +public record OpenAiChatResult +{ + public static OpenAiChatResult Success(List choices) + { + return new OpenAiChatResult(true, choices); + } + + public static OpenAiChatResult Failure() + { + return new OpenAiChatResult(false, new List()); + } + + private OpenAiChatResult( + bool isSuccess, + List choices) + { + IsSuccess = isSuccess; + Choices = choices; + } + + public bool IsSuccess { get; } + + public List Choices { get; } +} \ No newline at end of file diff --git a/src/Infrastructure/Services/OpenAi/OpenAiService.cs b/src/Infrastructure/Services/OpenAi/OpenAiService.cs index d54df447..2c621340 100644 --- a/src/Infrastructure/Services/OpenAi/OpenAiService.cs +++ b/src/Infrastructure/Services/OpenAi/OpenAiService.cs @@ -1,8 +1,7 @@ using System.Net.Http.Headers; -using System.Net.Http.Json; using System.Text; using System.Text.Json; -using Infrastructure.Jwt; +using Domain.Entities.Companies; using Infrastructure.Services.OpenAi.Models; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -11,75 +10,167 @@ namespace Infrastructure.Services.OpenAi; public class OpenAiService : IOpenAiService { + private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; private readonly IConfiguration _configuration; - private readonly ILogger _logger; public OpenAiService( + ILogger logger, IHttpClientFactory httpClientFactory, - IConfiguration configuration, - ILogger logger) + IConfiguration configuration) { + _logger = logger; _httpClientFactory = httpClientFactory; _configuration = configuration; - _logger = logger; } - public string GetBearer() + public Task AnalyzeCompanyAsync( + Company company, + string correlationId = null, + CancellationToken cancellationToken = default) + { + if (company == null || + !company.HasRelevantReviews()) + { + throw new InvalidOperationException("Company does not have relevant reviews."); + } + + const string systemPrompt = + "You are a helpful career assistant. " + + "Analyze the company's reviews and provide " + + "a summary with advice what should user pay more attention on. " + + "In the request there will be a company total rating, rating history and reviews presented in JSON format."; + + var input = JsonSerializer.Serialize( + new CompanyAnalyzeRequest(company)); + + return AnalyzeChatAsync( + input, + systemPrompt, + correlationId, + cancellationToken); + } + + public Task AnalyzeChatAsync( + string input, + string correlationId = null, + CancellationToken cancellationToken = default) { - var secret = _configuration["OpenAiApiSecret"]; - return new TechinterviewJwtTokenGenerator(secret).ToString(); + return AnalyzeChatAsync( + input, + "You are a helpful assistant. Analyze the user's input and provide a response.", + correlationId, + cancellationToken); } - public async Task GetAnalysisAsync( - OpenAiBodyReport report, + private async Task AnalyzeChatAsync( + string input, + string systemPrompt, + string correlationId = null, CancellationToken cancellationToken = default) { - var apiUrl = _configuration["OpenAiApiUrl"]; - if (string.IsNullOrEmpty(apiUrl)) + var apiKey = _configuration["OpenAI:ApiKey"]; + var model = _configuration["OpenAI:Model"]; + var baseUrl = _configuration["OpenAI:BaseUrl"]; + + if (string.IsNullOrEmpty(apiKey) || + string.IsNullOrEmpty(model) || + string.IsNullOrEmpty(baseUrl)) { - throw new InvalidOperationException("OpenAI API url is not set"); + _logger.LogError( + "OpenAI configuration is missing. " + + "CorrelationId: {CorrelationId}. " + + "ApiKey: {ApiKey}, " + + "Model: {Model}, " + + "BaseUrl: {BaseUrl}", + correlationId, + apiKey?.Length.ToString() ?? "-", + model, + baseUrl); + + throw new InvalidOperationException("OpenAI configuration is not set."); } - var responseContent = string.Empty; - try + var request = new ChatRequest { - using var client = _httpClientFactory.CreateClient(); + Model = model, + Messages = + [ + new ChatMessage + { + Role = "system", + Content = systemPrompt, + }, + + new ChatMessage + { + Role = "user", + Content = input + }, + ] + }; + + var requestJson = JsonSerializer.Serialize(request); + var httpRequest = new HttpRequestMessage( + HttpMethod.Post, + $"{baseUrl}chat/completions"); + + httpRequest.Headers.Authorization = new AuthenticationHeaderValue( + "Bearer", + apiKey); - client.BaseAddress = new Uri(apiUrl); - var request = new HttpRequestMessage(HttpMethod.Post, apiUrl); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", GetBearer()); - request.Content = new StringContent( - JsonSerializer.Serialize(report), - Encoding.UTF8, - "application/json"); + httpRequest.Content = new StringContent( + requestJson, + Encoding.UTF8, + "application/json"); - var response = await client.SendAsync(request, cancellationToken); + var client = _httpClientFactory.CreateClient("OpenAI"); - responseContent = await response.Content.ReadAsStringAsync(cancellationToken); - if (response.IsSuccessStatusCode) + try + { + var response = await client.SendAsync(httpRequest, cancellationToken); + var responseJson = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) { - return responseContent; + _logger.LogError( + "OpenAI API request failed. " + + "CorrelationId: {CorrelationId}. " + + "StatusCode: {StatusCode}, " + + "ReasonPhrase: {ReasonPhrase}. " + + "Response: {Response}", + correlationId, + response.StatusCode, + response.ReasonPhrase, + responseJson); } - _logger.LogError( - "Failed request to OpenAI {Url}. Status {Status}, Response {Response}", - apiUrl, - response.StatusCode, - responseContent); + var responseDeserialized = JsonSerializer.Deserialize(responseJson); + if (responseDeserialized?.Choices == null) + { + _logger.LogError( + "OpenAI API response deserialization failed. " + + "CorrelationId: {CorrelationId}. " + + "Response: {Response}", + correlationId, + responseJson); + + return OpenAiChatResult.Failure(); + } - return string.Empty; + return OpenAiChatResult.Success(responseDeserialized.Choices); } catch (Exception e) { _logger.LogError( e, - "Error while getting OpenAI response from {Url}. Message {Message}. Response {Response}", - apiUrl, - e.Message, - responseContent); + "An error occurred while calling OpenAI API. " + + "CorrelationId: {CorrelationId}. " + + "Input: {Input}", + correlationId, + input); - return string.Empty; + return OpenAiChatResult.Failure(); } } } \ No newline at end of file diff --git a/src/Web.Api/Features/BackgroundJobs/AiAnalysisSubscriptionJob.cs b/src/Web.Api/Features/BackgroundJobs/AiAnalysisSubscriptionJob.cs index b54b55e1..0bfd1a86 100644 --- a/src/Web.Api/Features/BackgroundJobs/AiAnalysisSubscriptionJob.cs +++ b/src/Web.Api/Features/BackgroundJobs/AiAnalysisSubscriptionJob.cs @@ -9,7 +9,8 @@ using Infrastructure.Database; using Infrastructure.Salaries; using Infrastructure.Services.OpenAi; -using Infrastructure.Services.OpenAi.Models; +using Infrastructure.Services.OpenAi.Custom; +using Infrastructure.Services.OpenAi.Custom.Models; using Infrastructure.Services.Professions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -21,13 +22,13 @@ public class AiAnalysisSubscriptionJob { private readonly DatabaseContext _context; private readonly IProfessionsCacheService _professionsCacheService; - private readonly IOpenAiService _openAiService; + private readonly ICustomOpenAiService _openAiService; public AiAnalysisSubscriptionJob( ILogger logger, DatabaseContext context, IProfessionsCacheService professionsCacheService, - IOpenAiService openAiService) + ICustomOpenAiService openAiService) : base(logger) { _context = context; diff --git a/src/Web.Api/Features/Companies/CompaniesController.cs b/src/Web.Api/Features/Companies/CompaniesController.cs index 9f535b88..e134f2eb 100644 --- a/src/Web.Api/Features/Companies/CompaniesController.cs +++ b/src/Web.Api/Features/Companies/CompaniesController.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection; using Web.Api.Features.Companies.CreateCompany; using Web.Api.Features.Companies.GetCompany; +using Web.Api.Features.Companies.GetCompanyAiAnalysis; using Web.Api.Features.Companies.GetCompanyByAdmin; using Web.Api.Features.Companies.SearchCompanies; using Web.Api.Features.Companies.SearchCompaniesForAdmin; @@ -99,6 +100,17 @@ await _serviceProvider.GetRequiredService() .Handle(companyIdentifier, cancellationToken)); } + [HttpGet("{companyIdentifier}/open-ai-analysis")] + [HasAnyRole(Role.Admin)] + public async Task GetCompanyAiAnalysis( + string companyIdentifier, + CancellationToken cancellationToken) + { + return Ok( + await _serviceProvider.GetRequiredService() + .Handle(companyIdentifier, cancellationToken)); + } + [HttpDelete("{companyId:guid}")] [HasAnyRole(Role.Admin)] public async Task DeleteCompany( diff --git a/src/Web.Api/Features/Companies/GetCompanyAiAnalysis/GetCompanyAiAnalysisHandler.cs b/src/Web.Api/Features/Companies/GetCompanyAiAnalysis/GetCompanyAiAnalysisHandler.cs new file mode 100644 index 00000000..48ac02ad --- /dev/null +++ b/src/Web.Api/Features/Companies/GetCompanyAiAnalysis/GetCompanyAiAnalysisHandler.cs @@ -0,0 +1,54 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Domain.Validation.Exceptions; +using Infrastructure.Database; +using Infrastructure.Extensions; +using Infrastructure.Services.Correlation; +using Infrastructure.Services.Mediator; +using Infrastructure.Services.OpenAi; +using Infrastructure.Services.OpenAi.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Web.Api.Features.Companies.GetCompanyAiAnalysis; + +public class GetCompanyAiAnalysisHandler + : IRequestHandler +{ + private readonly IOpenAiService _openAiService; + private readonly ILogger _logger; + private readonly DatabaseContext _context; + private readonly ICorrelationIdAccessor _correlationIdAccessor; + + public GetCompanyAiAnalysisHandler( + IOpenAiService openAiService, + ILogger logger, + DatabaseContext context, + ICorrelationIdAccessor correlationIdAccessor) + { + _openAiService = openAiService; + _logger = logger; + _context = context; + _correlationIdAccessor = correlationIdAccessor; + } + + public async Task Handle( + string companyId, + CancellationToken cancellationToken) + { + var company = await _context.Companies + .Include(x => x.RatingHistory) + .Include(x => x.Reviews) + .GetCompanyByIdentifierOrNullAsync( + companyId, + cancellationToken) + ?? throw new NotFoundException("Company not found."); + + return await _openAiService + .AnalyzeCompanyAsync( + company, + _correlationIdAccessor.GetValue(), + cancellationToken); + } +} \ No newline at end of file diff --git a/src/Web.Api/Features/Subscribtions/GetOpenAiReport/GetOpenAiReportHandler.cs b/src/Web.Api/Features/Subscribtions/GetOpenAiReport/GetOpenAiReportHandler.cs index d975fd07..498716bc 100644 --- a/src/Web.Api/Features/Subscribtions/GetOpenAiReport/GetOpenAiReportHandler.cs +++ b/src/Web.Api/Features/Subscribtions/GetOpenAiReport/GetOpenAiReportHandler.cs @@ -5,7 +5,7 @@ using Domain.Validation.Exceptions; using Infrastructure.Database; using Infrastructure.Salaries; -using Infrastructure.Services.OpenAi.Models; +using Infrastructure.Services.OpenAi.Custom.Models; using Infrastructure.Services.Professions; using Microsoft.EntityFrameworkCore; diff --git a/src/Web.Api/Features/Subscribtions/GetOpenAiReportAnalysis/GetOpenAiReportAnalysisHandler.cs b/src/Web.Api/Features/Subscribtions/GetOpenAiReportAnalysis/GetOpenAiReportAnalysisHandler.cs index 5e3f7bdb..684ca9b7 100644 --- a/src/Web.Api/Features/Subscribtions/GetOpenAiReportAnalysis/GetOpenAiReportAnalysisHandler.cs +++ b/src/Web.Api/Features/Subscribtions/GetOpenAiReportAnalysis/GetOpenAiReportAnalysisHandler.cs @@ -6,7 +6,8 @@ using Infrastructure.Database; using Infrastructure.Salaries; using Infrastructure.Services.OpenAi; -using Infrastructure.Services.OpenAi.Models; +using Infrastructure.Services.OpenAi.Custom; +using Infrastructure.Services.OpenAi.Custom.Models; using Infrastructure.Services.Professions; using Microsoft.EntityFrameworkCore; @@ -16,12 +17,12 @@ public class GetOpenAiReportAnalysisHandler : Infrastructure.Services.Mediator.I { private readonly DatabaseContext _context; private readonly IProfessionsCacheService _professionsCacheService; - private readonly IOpenAiService _openApiService; + private readonly ICustomOpenAiService _openApiService; public GetOpenAiReportAnalysisHandler( DatabaseContext context, IProfessionsCacheService professionsCacheService, - IOpenAiService openApiService) + ICustomOpenAiService openApiService) { _context = context; _professionsCacheService = professionsCacheService; diff --git a/src/Web.Api/Features/Subscribtions/GetOpenAiReportAnalysis/GetOpenAiReportAnalysisResponse.cs b/src/Web.Api/Features/Subscribtions/GetOpenAiReportAnalysis/GetOpenAiReportAnalysisResponse.cs index 6543e738..856c8d52 100644 --- a/src/Web.Api/Features/Subscribtions/GetOpenAiReportAnalysis/GetOpenAiReportAnalysisResponse.cs +++ b/src/Web.Api/Features/Subscribtions/GetOpenAiReportAnalysis/GetOpenAiReportAnalysisResponse.cs @@ -1,4 +1,4 @@ -using Infrastructure.Services.OpenAi.Models; +using Infrastructure.Services.OpenAi.Custom.Models; namespace Web.Api.Features.Subscribtions.GetOpenAiReportAnalysis; diff --git a/src/Web.Api/Features/Subscribtions/TelegramSubscriptionsController.cs b/src/Web.Api/Features/Subscribtions/TelegramSubscriptionsController.cs index 3ce98985..58ccbda9 100644 --- a/src/Web.Api/Features/Subscribtions/TelegramSubscriptionsController.cs +++ b/src/Web.Api/Features/Subscribtions/TelegramSubscriptionsController.cs @@ -4,7 +4,7 @@ using Domain.Enums; using Domain.ValueObjects.Pagination; using Infrastructure.Services.Mediator; -using Infrastructure.Services.OpenAi.Models; +using Infrastructure.Services.OpenAi.Custom.Models; using Microsoft.AspNetCore.Mvc; using Web.Api.Features.Subscribtions.ActivateSubscription; using Web.Api.Features.Subscribtions.CreateSubscription; diff --git a/src/Web.Api/Setup/ServiceRegistration.cs b/src/Web.Api/Setup/ServiceRegistration.cs index d06dc323..3506021b 100644 --- a/src/Web.Api/Setup/ServiceRegistration.cs +++ b/src/Web.Api/Setup/ServiceRegistration.cs @@ -14,6 +14,7 @@ using Infrastructure.Services.Html; using Infrastructure.Services.Http; using Infrastructure.Services.OpenAi; +using Infrastructure.Services.OpenAi.Custom; using Infrastructure.Services.PDF; using Infrastructure.Services.PDF.Interviews; using Infrastructure.Services.Professions; @@ -45,7 +46,7 @@ public static IServiceCollection SetupAppServices( .AddScoped() .AddScoped() .AddScoped() - .AddScoped() + .AddScoped() .AddTransient() .AddTransient() .AddTransient() @@ -56,7 +57,8 @@ public static IServiceCollection SetupAppServices( .AddScoped() .AddScoped() .AddScoped() - .AddScoped(); + .AddScoped() + .AddScoped(); // https://github.com/rdvojmoc/DinkToPdf/#dependency-injection // services.AddSingleton(); diff --git a/src/Web.Api/appsettings.Production.json b/src/Web.Api/appsettings.Production.json index 8f1c7c3c..51f1c955 100644 --- a/src/Web.Api/appsettings.Production.json +++ b/src/Web.Api/appsettings.Production.json @@ -41,5 +41,8 @@ "ConnectionStrings": { "Database": "__DATABASE_CONNECTION_STRING", "Elasticsearch": "http://interviewer-elasticsearch:9200" + }, + "OpenAI": { + "ApiKey": "__OPENAI_API_KEY" } } diff --git a/src/Web.Api/appsettings.json b/src/Web.Api/appsettings.json index a92778fc..2768c141 100644 --- a/src/Web.Api/appsettings.json +++ b/src/Web.Api/appsettings.json @@ -17,11 +17,12 @@ }, "Telegram": { "SalariesBotToken": "__TELEGRAM_BOT_KEY", - "SalariesBotEnable": "true", + "SalariesBotEnable": "false", "GithubProfileBotToken": "__PROFILE_GITHUB_BOT_KEY", "GithubProfileBotEnable": "true", "GithubPATForLocalDevelopment": "", - "AdminUsername": "maximgorbatyuk" + "AdminUsername": "maximgorbatyuk", + "GithubTelegramBotName": "github_profile_bot" }, "SendGridApiKey": "", "SendGridWebhookSignature": "", @@ -49,5 +50,10 @@ }, "Currencies": { "Url": "https://nationalbank.kz/rss/rates_all.xml" + }, + "OpenAI": { + "ApiKey": "sk-xxxx", + "BaseUrl": "https://api.openai.com/v1/", + "Model": "gpt-4o" } }