diff --git a/src/Domain/Entities/OpenAI/OpenAiPrompt.cs b/src/Domain/Entities/OpenAI/OpenAiPrompt.cs index 8fcf0583..aa3ea20e 100644 --- a/src/Domain/Entities/OpenAI/OpenAiPrompt.cs +++ b/src/Domain/Entities/OpenAI/OpenAiPrompt.cs @@ -1,10 +1,11 @@ using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace Domain.Entities.OpenAI; -public class OpenAiPrompt : HasDatesBase +public class OpenAiPrompt : HasDatesBase, IHasIdBase { public const string DefaultCompanyAnalyzePrompt = "You are a helpful career assistant. " + @@ -16,6 +17,23 @@ public class OpenAiPrompt : HasDatesBase "You are a helpful assistant. Analyze the user's input and provide a response. " + "Your reply should be in question language, markdown formatted."; + private static readonly List _chatGptAllowedModels = new List + { + "gpt-3.5-turbo", + "gpt-4", + "gpt-4o", + }; + + private static readonly List _claudeAllowedModels = new List + { + "claude-3-5-haiku-20241022", + "claude-3-5-haiku-latest", + "claude-3-5-sonnet-20241022", + "claude-3-5-sonnet-latest", + "claude-sonnet-4-20250514", + "claude-sonnet-4-0", + }; + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] [Key] public OpenAiPromptType Id { get; protected set; } @@ -33,9 +51,11 @@ public OpenAiPrompt( AiEngine engine) { Id = id; - Prompt = prompt; - Model = model; + Prompt = prompt?.Trim(); + Model = model?.Trim().ToLowerInvariant(); Engine = engine; + + ValidateModel(); } // for migrations @@ -56,4 +76,43 @@ public OpenAiPrompt( protected OpenAiPrompt() { } + + public void Update( + string prompt, + string model, + AiEngine engine) + { + prompt = prompt?.Trim(); + if (string.IsNullOrEmpty(prompt)) + { + throw new InvalidOperationException("Prompt cannot be null or empty."); + } + + model = model?.Trim().ToLowerInvariant(); + if (string.IsNullOrEmpty(model)) + { + throw new InvalidOperationException("Model cannot be null or empty."); + } + + Prompt = prompt; + Model = model; + Engine = engine; + + ValidateModel(); + } + + public void ValidateModel() + { + if (Engine is AiEngine.OpenAi && + !_chatGptAllowedModels.Contains(Model)) + { + throw new InvalidOperationException($"Model '{Model}' is not allowed for OpenAI engine."); + } + + if (Engine is AiEngine.Claude && + !_claudeAllowedModels.Contains(Model)) + { + throw new InvalidOperationException($"Model '{Model}' is not allowed for Claude engine."); + } + } } \ No newline at end of file diff --git a/src/Infrastructure/Database/ContextExtensions.cs b/src/Infrastructure/Database/ContextExtensions.cs index 52aa8e70..7197115a 100644 --- a/src/Infrastructure/Database/ContextExtensions.cs +++ b/src/Infrastructure/Database/ContextExtensions.cs @@ -130,6 +130,14 @@ public static async Task ByIdOrFailAsync( ?? throw NotFoundException.CreateFromEntity(id); } + public static Task ByIdOrNullAsync( + this IQueryable query, + TKey id, + CancellationToken cancellationToken = default) + where T : class, IHasIdBase + where TKey : struct => + query.FirstOrDefaultAsync(x => x.Id.Equals(id), cancellationToken); + public static async Task> AsPaginatedAsync( this IQueryable query, PageModel pageModelOrNull = null, diff --git a/src/Web.Api/Features/Admin/AiPrompts/AiPromptController.cs b/src/Web.Api/Features/Admin/AiPrompts/AiPromptController.cs new file mode 100644 index 00000000..ca12a211 --- /dev/null +++ b/src/Web.Api/Features/Admin/AiPrompts/AiPromptController.cs @@ -0,0 +1,128 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Domain.Entities.OpenAI; +using Domain.Enums; +using Domain.Validation.Exceptions; +using Infrastructure.Authentication.Contracts; +using Infrastructure.Database; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Web.Api.Features.Admin.AiPrompts.Models; +using Web.Api.Setup.Attributes; + +namespace Web.Api.Features.Admin.AiPrompts; + +[HasAnyRole(Role.Admin)] +[ApiController] +[Route("api/admin/ai-prompts")] +public class AiPromptController : ControllerBase +{ + private readonly IAuthorization _auth; + private readonly DatabaseContext _context; + + public AiPromptController( + IAuthorization auth, + DatabaseContext context) + { + _auth = auth; + _context = context; + } + + [HttpGet("")] + public async Task> All( + CancellationToken cancellationToken) + { + await _auth.HasRoleOrFailAsync(Role.Admin, cancellationToken); + + return await _context.OpenAiPrompts + .OrderBy(x => x.Id) + .Select(x => new OpenAiPromptDto + { + Id = x.Id, + Prompt = x.Prompt, + Model = x.Model, + Engine = x.Engine, + CreatedAt = x.CreatedAt, + UpdatedAt = x.UpdatedAt + }) + .AsNoTracking() + .ToListAsync(cancellationToken); + } + + [HttpPost("")] + public async Task Create( + [FromBody] OpenAiPromptEditRequest createRequest, + CancellationToken cancellationToken) + { + await _auth.HasRoleOrFailAsync(Role.Admin, cancellationToken); + + if (!createRequest.Id.HasValue) + { + throw new BadRequestException("Id is required for prompt creation"); + } + + if (createRequest.Engine is AiEngine.Undefined) + { + throw new BadRequestException("Engine is required for prompt creation"); + } + + if (await _context.OpenAiPrompts.AnyAsync( + x => x.Id == createRequest.Id.Value, + cancellationToken: cancellationToken)) + { + throw new BadRequestException("Prompt with this ID already exists"); + } + + var item = await _context.SaveAsync( + new OpenAiPrompt( + createRequest.Id.Value, + createRequest.Prompt, + createRequest.Model, + createRequest.Engine), + cancellationToken); + + return Ok(item.Id); + } + + [HttpPut("")] + public async Task Update( + [FromBody] OpenAiPromptEditRequest updateRequest, + CancellationToken cancellationToken) + { + await _auth.HasRoleOrFailAsync(Role.Admin, cancellationToken); + + if (!updateRequest.Id.HasValue) + { + throw new BadRequestException("Id is required for OpenAI prompt update"); + } + + var entity = await _context.OpenAiPrompts + .FirstOrDefaultAsync(x => x.Id == updateRequest.Id.Value, cancellationToken); + + entity.Update( + updateRequest.Prompt, + updateRequest.Model, + updateRequest.Engine); + + await _context.TrySaveChangesAsync(cancellationToken); + return Ok(); + } + + [HttpDelete("{id}")] + public async Task Delete( + [FromRoute] OpenAiPromptType id, + CancellationToken cancellationToken) + { + await _auth.HasRoleOrFailAsync(Role.Admin, cancellationToken); + + var entity = await _context.OpenAiPrompts + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + + _context.Remove(entity); + await _context.TrySaveChangesAsync(cancellationToken); + + return Ok(); + } +} \ No newline at end of file diff --git a/src/Web.Api/Features/Admin/AiPrompts/Models/OpenAiPromptDto.cs b/src/Web.Api/Features/Admin/AiPrompts/Models/OpenAiPromptDto.cs new file mode 100644 index 00000000..aae6ec81 --- /dev/null +++ b/src/Web.Api/Features/Admin/AiPrompts/Models/OpenAiPromptDto.cs @@ -0,0 +1,19 @@ +using System; +using Domain.Entities.OpenAI; + +namespace Web.Api.Features.Admin.AiPrompts.Models; + +public record OpenAiPromptDto +{ + public OpenAiPromptType Id { get; init; } + + public string Prompt { get; init; } + + public string Model { get; init; } + + public AiEngine Engine { get; init; } + + public DateTimeOffset CreatedAt { get; init; } + + public DateTimeOffset UpdatedAt { get; init; } +} \ No newline at end of file diff --git a/src/Web.Api/Features/Admin/AiPrompts/Models/OpenAiPromptEditRequest.cs b/src/Web.Api/Features/Admin/AiPrompts/Models/OpenAiPromptEditRequest.cs new file mode 100644 index 00000000..c62ab387 --- /dev/null +++ b/src/Web.Api/Features/Admin/AiPrompts/Models/OpenAiPromptEditRequest.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using Domain.Entities.OpenAI; + +namespace Web.Api.Features.Admin.AiPrompts.Models; + +public record OpenAiPromptEditRequest +{ + public OpenAiPromptType? Id { get; init; } + + [Required] + public string Prompt { get; init; } + + [Required] + public string Model { get; init; } + + [Required] + public AiEngine Engine { get; init; } +} \ No newline at end of file