diff --git a/TalentManagementAPI.Application/Interfaces/IAiChatService.cs b/TalentManagementAPI.Application/Interfaces/IAiChatService.cs new file mode 100644 index 0000000..9b3bf3b --- /dev/null +++ b/TalentManagementAPI.Application/Interfaces/IAiChatService.cs @@ -0,0 +1,7 @@ +namespace TalentManagementAPI.Application.Interfaces +{ + public interface IAiChatService + { + Task ChatAsync(string message, string? systemPrompt = null, CancellationToken cancellationToken = default); + } +} diff --git a/TalentManagementAPI.Infrastructure.Shared/GlobalUsings.cs b/TalentManagementAPI.Infrastructure.Shared/GlobalUsings.cs index cb46649..b231087 100644 --- a/TalentManagementAPI.Infrastructure.Shared/GlobalUsings.cs +++ b/TalentManagementAPI.Infrastructure.Shared/GlobalUsings.cs @@ -1,6 +1,7 @@ global using System; global using System.Collections.Generic; global using System.Linq; +global using System.Threading; global using System.Threading.Tasks; global using AutoBogus; global using Bogus; @@ -12,6 +13,8 @@ global using Microsoft.Extensions.Logging; global using Microsoft.Extensions.Options; global using MimeKit; +global using OllamaSharp; +global using OllamaSharp.Models.Chat; global using TalentManagementAPI.Application.DTOs.Email; global using TalentManagementAPI.Application.Exceptions; global using TalentManagementAPI.Application.Interfaces; diff --git a/TalentManagementAPI.Infrastructure.Shared/ServiceRegistration.cs b/TalentManagementAPI.Infrastructure.Shared/ServiceRegistration.cs index 0bbaf81..f782050 100644 --- a/TalentManagementAPI.Infrastructure.Shared/ServiceRegistration.cs +++ b/TalentManagementAPI.Infrastructure.Shared/ServiceRegistration.cs @@ -1,13 +1,23 @@ -namespace TalentManagementAPI.Infrastructure.Shared +using TalentManagementAPI.Application.Interfaces; +using TalentManagementAPI.Infrastructure.Shared.Services; + +namespace TalentManagementAPI.Infrastructure.Shared { public static class ServiceRegistration { - public static void AddSharedInfrastructure(this IServiceCollection services, IConfiguration _config) + public static void AddSharedInfrastructure(this IServiceCollection services, IConfiguration config) { - services.Configure(_config.GetSection("MailSettings")); + services.Configure(config.GetSection("MailSettings")); services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddSingleton(_ => + { + var baseUrl = config["Ollama:BaseUrl"] ?? "http://localhost:11434"; + var model = config["Ollama:Model"] ?? "llama3.2"; + return new OllamaApiClient(new Uri(baseUrl), model); + }); + services.AddTransient(); } } } diff --git a/TalentManagementAPI.Infrastructure.Shared/Services/OllamaAiService.cs b/TalentManagementAPI.Infrastructure.Shared/Services/OllamaAiService.cs new file mode 100644 index 0000000..5f99c71 --- /dev/null +++ b/TalentManagementAPI.Infrastructure.Shared/Services/OllamaAiService.cs @@ -0,0 +1,45 @@ +using TalentManagementAPI.Application.Interfaces; + +namespace TalentManagementAPI.Infrastructure.Shared.Services +{ + public class OllamaAiService : IAiChatService + { + private readonly IOllamaApiClient _ollamaApiClient; + + public OllamaAiService(IOllamaApiClient ollamaApiClient) + { + _ollamaApiClient = ollamaApiClient; + } + + public async Task ChatAsync(string message, string? systemPrompt = null, CancellationToken cancellationToken = default) + { + var messages = new List(); + + if (!string.IsNullOrWhiteSpace(systemPrompt)) + { + messages.Add(new Message(new ChatRole("system"), systemPrompt)); + } + + messages.Add(new Message(new ChatRole("user"), message)); + + var request = new ChatRequest + { + Model = _ollamaApiClient.SelectedModel, + Messages = messages, + Stream = true + }; + + var responseBuilder = new MessageBuilder(); + + await foreach (var response in _ollamaApiClient.ChatAsync(request, cancellationToken).WithCancellation(cancellationToken)) + { + if (response?.Message is not null) + { + responseBuilder.Append(response); + } + } + + return responseBuilder.HasValue ? responseBuilder.ToMessage().Content ?? string.Empty : string.Empty; + } + } +} diff --git a/TalentManagementAPI.Infrastructure.Shared/TalentManagementAPI.Infrastructure.Shared.csproj b/TalentManagementAPI.Infrastructure.Shared/TalentManagementAPI.Infrastructure.Shared.csproj index ae9de90..c68d0b9 100644 --- a/TalentManagementAPI.Infrastructure.Shared/TalentManagementAPI.Infrastructure.Shared.csproj +++ b/TalentManagementAPI.Infrastructure.Shared/TalentManagementAPI.Infrastructure.Shared.csproj @@ -9,10 +9,11 @@ - - - - + + + + + diff --git a/TalentManagementAPI.Infrastructure.Tests/Services/OllamaAiServiceTests.cs b/TalentManagementAPI.Infrastructure.Tests/Services/OllamaAiServiceTests.cs new file mode 100644 index 0000000..dd238b9 --- /dev/null +++ b/TalentManagementAPI.Infrastructure.Tests/Services/OllamaAiServiceTests.cs @@ -0,0 +1,70 @@ +using OllamaSharp.Models.Chat; + +namespace TalentManagementAPI.Infrastructure.Tests.Services +{ + public class OllamaAiServiceTests + { + [Fact] + public async Task ChatAsync_WithSystemPrompt_SendsPromptAndReturnsCombinedReply() + { + var client = new Mock(); + client.SetupProperty(x => x.SelectedModel, "llama3.2"); + + ChatRequest? capturedRequest = null; + + client + .Setup(x => x.ChatAsync(It.IsAny(), It.IsAny())) + .Returns((ChatRequest request, CancellationToken _) => + { + capturedRequest = request; + return StreamResponses( + new ChatResponseStream + { + Message = new Message(new ChatRole("assistant"), "Hello") + }, + new ChatResponseStream + { + Message = new Message(new ChatRole("assistant"), " world") + }); + }); + + var service = new OllamaAiService(client.Object); + + var reply = await service.ChatAsync("Say hi", "You are concise."); + + reply.Should().Be("Hello world"); + capturedRequest.Should().NotBeNull(); + capturedRequest!.Model.Should().Be("llama3.2"); + capturedRequest.Messages.Should().HaveCount(2); + capturedRequest.Messages[0].Role.Should().Be(new ChatRole("system")); + capturedRequest.Messages[0].Content.Should().Be("You are concise."); + capturedRequest.Messages[1].Role.Should().Be(new ChatRole("user")); + capturedRequest.Messages[1].Content.Should().Be("Say hi"); + } + + [Fact] + public async Task ChatAsync_WithoutChunks_ReturnsEmptyString() + { + var client = new Mock(); + client.SetupProperty(x => x.SelectedModel, "llama3.2"); + client + .Setup(x => x.ChatAsync(It.IsAny(), It.IsAny())) + .Returns(StreamResponses()); + + var service = new OllamaAiService(client.Object); + + var reply = await service.ChatAsync("Say hi"); + + reply.Should().BeEmpty(); + } + + private static async IAsyncEnumerable StreamResponses(params ChatResponseStream[] responses) + { + foreach (var response in responses) + { + yield return response; + await Task.Yield(); + } + } + } +} diff --git a/TalentManagementAPI.WebApi.Tests/Controllers/AiControllerTests.cs b/TalentManagementAPI.WebApi.Tests/Controllers/AiControllerTests.cs new file mode 100644 index 0000000..17737d1 --- /dev/null +++ b/TalentManagementAPI.WebApi.Tests/Controllers/AiControllerTests.cs @@ -0,0 +1,51 @@ +namespace TalentManagementAPI.WebApi.Tests.Controllers +{ + public class AiControllerTests + { + private readonly Mock _aiChatServiceMock = new(); + private readonly Mock _featureManagerMock = new(); + private readonly AiController _controller; + + public AiControllerTests() + { + _controller = new AiController(_aiChatServiceMock.Object, _featureManagerMock.Object); + } + + [Fact] + public async Task Chat_AiDisabled_ReturnsServiceUnavailableProblemDetails() + { + _featureManagerMock + .Setup(m => m.IsEnabledAsync("AiEnabled")) + .ReturnsAsync(false); + + var result = await _controller.Chat(new AiChatRequest("hello"), CancellationToken.None); + + var objectResult = result.Should().BeOfType().Subject; + objectResult.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable); + + var problem = objectResult.Value.Should().BeOfType().Subject; + problem.Title.Should().Be("AI chat is disabled"); + problem.Detail.Should().Be("AI chat is disabled. Enable FeatureManagement:AiEnabled to use this endpoint."); + + _aiChatServiceMock.Verify( + m => m.ChatAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task Chat_AiEnabled_ReturnsOkWithReply() + { + _featureManagerMock + .Setup(m => m.IsEnabledAsync("AiEnabled")) + .ReturnsAsync(true); + _aiChatServiceMock + .Setup(m => m.ChatAsync("hello", null, It.IsAny())) + .ReturnsAsync("hi"); + + var result = await _controller.Chat(new AiChatRequest("hello"), CancellationToken.None); + + var okResult = result.Should().BeOfType().Subject; + okResult.Value.Should().BeEquivalentTo(new AiChatResponse("hi")); + } + } +} diff --git a/TalentManagementAPI.WebApi/Controllers/v1/AiController.cs b/TalentManagementAPI.WebApi/Controllers/v1/AiController.cs new file mode 100644 index 0000000..4c62ab4 --- /dev/null +++ b/TalentManagementAPI.WebApi/Controllers/v1/AiController.cs @@ -0,0 +1,46 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using TalentManagementAPI.Application.Interfaces; + +namespace TalentManagementAPI.WebApi.Controllers.v1 +{ + [ApiVersion("1.0")] + [AllowAnonymous] + [Route("api/v{version:apiVersion}/ai")] + public sealed class AiController : BaseApiController + { + private readonly IAiChatService _aiChatService; + private readonly IFeatureManagerSnapshot _featureManager; + + public AiController(IAiChatService aiChatService, IFeatureManagerSnapshot featureManager) + { + _aiChatService = aiChatService; + _featureManager = featureManager; + } + + /// + /// Send a message to the AI assistant and receive a reply. + /// + /// The chat message and optional system prompt. + /// Cancellation token. + /// The AI-generated reply. + [HttpPost("chat")] + public async Task Chat([FromBody] AiChatRequest request, CancellationToken cancellationToken) + { + if (!await _featureManager.IsEnabledAsync("AiEnabled")) + { + return Problem( + detail: "AI chat is disabled. Enable FeatureManagement:AiEnabled to use this endpoint.", + title: "AI chat is disabled", + statusCode: StatusCodes.Status503ServiceUnavailable); + } + + var reply = await _aiChatService.ChatAsync(request.Message, request.SystemPrompt, cancellationToken); + return Ok(new AiChatResponse(reply)); + } + } + + public record AiChatRequest(string Message, string? SystemPrompt = null); + public record AiChatResponse(string Reply); +} diff --git a/TalentManagementAPI.WebApi/Program.cs b/TalentManagementAPI.WebApi/Program.cs index c20b60c..021e473 100644 --- a/TalentManagementAPI.WebApi/Program.cs +++ b/TalentManagementAPI.WebApi/Program.cs @@ -19,6 +19,8 @@ builder.Services.AddApplicationLayer(); builder.Services.AddPersistenceInfrastructure(builder.Configuration); builder.Services.AddSharedInfrastructure(builder.Configuration); + // Register Ollama chat client (IChatClient) — used by OllamaAiService + // AiController is gated by [FeatureGate("AiEnabled")], so no calls are made when AI is disabled builder.Services.AddEasyCachingInfrastructure(builder.Configuration); builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); diff --git a/TalentManagementAPI.WebApi/TalentManagementAPI.WebApi.xml b/TalentManagementAPI.WebApi/TalentManagementAPI.WebApi.xml index 7129816..7442187 100644 --- a/TalentManagementAPI.WebApi/TalentManagementAPI.WebApi.xml +++ b/TalentManagementAPI.WebApi/TalentManagementAPI.WebApi.xml @@ -4,6 +4,14 @@ TalentManagementAPI.WebApi + + + Send a message to the AI assistant and receive a reply. + + The chat message and optional system prompt. + Cancellation token. + The AI-generated reply. + Gets a list of departments based on the specified filter. diff --git a/TalentManagementAPI.WebApi/appsettings.Development.json b/TalentManagementAPI.WebApi/appsettings.Development.json index e59fb07..671a7de 100644 --- a/TalentManagementAPI.WebApi/appsettings.Development.json +++ b/TalentManagementAPI.WebApi/appsettings.Development.json @@ -10,6 +10,7 @@ "ExecutionTimingIncludeHeader": true, "ExecutionTimingIncludePayload": true, "ExecutionTimingLogTimings": true, + "AiEnabled": true, "UseInMemoryDatabase": false }, "MailSettings": { diff --git a/TalentManagementAPI.WebApi/appsettings.json b/TalentManagementAPI.WebApi/appsettings.json index b27ecb2..f865f29 100644 --- a/TalentManagementAPI.WebApi/appsettings.json +++ b/TalentManagementAPI.WebApi/appsettings.json @@ -106,7 +106,12 @@ "ExecutionTimingIncludeHeader": true, "ExecutionTimingIncludePayload": true, "ExecutionTimingLogTimings": false, - "UseInMemoryDatabase": false + "UseInMemoryDatabase": false, + "AiEnabled": false + }, + "Ollama": { + "BaseUrl": "http://localhost:11434", + "Model": "llama3.2" }, "ApiRoles": { "EmployeeRole": "Employee",