From 010144395c4e19e824edab0154ccf8c581ad624c Mon Sep 17 00:00:00 2001 From: Fuji Nguyen Date: Sun, 15 Mar 2026 23:14:42 -0400 Subject: [PATCH 1/3] Add Ollama AI foundation: IAiChatService, OllamaAiService, AiController with feature flag gating --- .../Interfaces/IAiChatService.cs | 7 ++++ .../ServiceRegistration.cs | 6 ++- .../Services/OllamaAiService.cs | 28 ++++++++++++++ ...ManagementAPI.Infrastructure.Shared.csproj | 1 + .../Controllers/v1/AiController.cs | 38 +++++++++++++++++++ TalentManagementAPI.WebApi/Program.cs | 5 +++ .../TalentManagementAPI.WebApi.csproj | 1 + TalentManagementAPI.WebApi/appsettings.json | 7 +++- 8 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 TalentManagementAPI.Application/Interfaces/IAiChatService.cs create mode 100644 TalentManagementAPI.Infrastructure.Shared/Services/OllamaAiService.cs create mode 100644 TalentManagementAPI.WebApi/Controllers/v1/AiController.cs 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/ServiceRegistration.cs b/TalentManagementAPI.Infrastructure.Shared/ServiceRegistration.cs index 0bbaf81..bffa6c8 100644 --- a/TalentManagementAPI.Infrastructure.Shared/ServiceRegistration.cs +++ b/TalentManagementAPI.Infrastructure.Shared/ServiceRegistration.cs @@ -1,4 +1,7 @@ -namespace TalentManagementAPI.Infrastructure.Shared +using TalentManagementAPI.Application.Interfaces; +using TalentManagementAPI.Infrastructure.Shared.Services; + +namespace TalentManagementAPI.Infrastructure.Shared { public static class ServiceRegistration { @@ -8,6 +11,7 @@ public static void AddSharedInfrastructure(this IServiceCollection services, ICo services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); } } } diff --git a/TalentManagementAPI.Infrastructure.Shared/Services/OllamaAiService.cs b/TalentManagementAPI.Infrastructure.Shared/Services/OllamaAiService.cs new file mode 100644 index 0000000..e527908 --- /dev/null +++ b/TalentManagementAPI.Infrastructure.Shared/Services/OllamaAiService.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.AI; +using TalentManagementAPI.Application.Interfaces; + +namespace TalentManagementAPI.Infrastructure.Shared.Services +{ + public class OllamaAiService : IAiChatService + { + private readonly IChatClient _chatClient; + + public OllamaAiService(IChatClient chatClient) + { + _chatClient = chatClient; + } + + public async Task ChatAsync(string message, string? systemPrompt = null, CancellationToken cancellationToken = default) + { + var messages = new List(); + + if (!string.IsNullOrWhiteSpace(systemPrompt)) + messages.Add(new ChatMessage(ChatRole.System, systemPrompt)); + + messages.Add(new ChatMessage(ChatRole.User, message)); + + var response = await _chatClient.CompleteAsync(messages, cancellationToken: cancellationToken); + return response.Message.Text ?? string.Empty; + } + } +} diff --git a/TalentManagementAPI.Infrastructure.Shared/TalentManagementAPI.Infrastructure.Shared.csproj b/TalentManagementAPI.Infrastructure.Shared/TalentManagementAPI.Infrastructure.Shared.csproj index ae9de90..184cace 100644 --- a/TalentManagementAPI.Infrastructure.Shared/TalentManagementAPI.Infrastructure.Shared.csproj +++ b/TalentManagementAPI.Infrastructure.Shared/TalentManagementAPI.Infrastructure.Shared.csproj @@ -8,6 +8,7 @@ + diff --git a/TalentManagementAPI.WebApi/Controllers/v1/AiController.cs b/TalentManagementAPI.WebApi/Controllers/v1/AiController.cs new file mode 100644 index 0000000..4dd85bc --- /dev/null +++ b/TalentManagementAPI.WebApi/Controllers/v1/AiController.cs @@ -0,0 +1,38 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.FeatureManagement.Mvc; +using TalentManagementAPI.Application.Interfaces; + +namespace TalentManagementAPI.WebApi.Controllers.v1 +{ + [FeatureGate("AiEnabled")] + [ApiVersion("1.0")] + [AllowAnonymous] + [Route("api/v{version:apiVersion}/ai")] + public sealed class AiController : BaseApiController + { + private readonly IAiChatService _aiChatService; + + public AiController(IAiChatService aiChatService) + { + _aiChatService = aiChatService; + } + + /// + /// 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) + { + 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..e5a11f7 100644 --- a/TalentManagementAPI.WebApi/Program.cs +++ b/TalentManagementAPI.WebApi/Program.cs @@ -19,6 +19,11 @@ 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 + var ollamaBaseUrl = builder.Configuration["Ollama:BaseUrl"] ?? "http://localhost:11434"; + var ollamaModel = builder.Configuration["Ollama:Model"] ?? "llama3.2"; + builder.Services.AddOllamaChatClient(ollamaModel, new Uri(ollamaBaseUrl)); builder.Services.AddEasyCachingInfrastructure(builder.Configuration); builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); diff --git a/TalentManagementAPI.WebApi/TalentManagementAPI.WebApi.csproj b/TalentManagementAPI.WebApi/TalentManagementAPI.WebApi.csproj index 6961435..ee84bb5 100644 --- a/TalentManagementAPI.WebApi/TalentManagementAPI.WebApi.csproj +++ b/TalentManagementAPI.WebApi/TalentManagementAPI.WebApi.csproj @@ -41,6 +41,7 @@ all + 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", From 7f302767b398a918addff6b79e93b30297d9dd3f Mon Sep 17 00:00:00 2001 From: Fuji Nguyen Date: Tue, 17 Mar 2026 22:25:54 -0400 Subject: [PATCH 2/3] Refactor AI service to use OllamaSharp instead of Microsoft.Extensions.AI --- .../GlobalUsings.cs | 3 ++ .../ServiceRegistration.cs | 10 ++++-- .../Services/OllamaAiService.cs | 35 ++++++++++++++----- ...ManagementAPI.Infrastructure.Shared.csproj | 10 +++--- TalentManagementAPI.WebApi/Program.cs | 3 -- .../TalentManagementAPI.WebApi.csproj | 1 - 6 files changed, 42 insertions(+), 20 deletions(-) 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 bffa6c8..f782050 100644 --- a/TalentManagementAPI.Infrastructure.Shared/ServiceRegistration.cs +++ b/TalentManagementAPI.Infrastructure.Shared/ServiceRegistration.cs @@ -5,12 +5,18 @@ 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 index e527908..5f99c71 100644 --- a/TalentManagementAPI.Infrastructure.Shared/Services/OllamaAiService.cs +++ b/TalentManagementAPI.Infrastructure.Shared/Services/OllamaAiService.cs @@ -1,28 +1,45 @@ -using Microsoft.Extensions.AI; using TalentManagementAPI.Application.Interfaces; namespace TalentManagementAPI.Infrastructure.Shared.Services { public class OllamaAiService : IAiChatService { - private readonly IChatClient _chatClient; + private readonly IOllamaApiClient _ollamaApiClient; - public OllamaAiService(IChatClient chatClient) + public OllamaAiService(IOllamaApiClient ollamaApiClient) { - _chatClient = chatClient; + _ollamaApiClient = ollamaApiClient; } public async Task ChatAsync(string message, string? systemPrompt = null, CancellationToken cancellationToken = default) { - var messages = new List(); + var messages = new List(); if (!string.IsNullOrWhiteSpace(systemPrompt)) - messages.Add(new ChatMessage(ChatRole.System, systemPrompt)); + { + messages.Add(new Message(new ChatRole("system"), systemPrompt)); + } - messages.Add(new ChatMessage(ChatRole.User, message)); + messages.Add(new Message(new ChatRole("user"), message)); - var response = await _chatClient.CompleteAsync(messages, cancellationToken: cancellationToken); - return response.Message.Text ?? string.Empty; + 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 184cace..c68d0b9 100644 --- a/TalentManagementAPI.Infrastructure.Shared/TalentManagementAPI.Infrastructure.Shared.csproj +++ b/TalentManagementAPI.Infrastructure.Shared/TalentManagementAPI.Infrastructure.Shared.csproj @@ -8,12 +8,12 @@ - - - - - + + + + + diff --git a/TalentManagementAPI.WebApi/Program.cs b/TalentManagementAPI.WebApi/Program.cs index e5a11f7..021e473 100644 --- a/TalentManagementAPI.WebApi/Program.cs +++ b/TalentManagementAPI.WebApi/Program.cs @@ -21,9 +21,6 @@ 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 - var ollamaBaseUrl = builder.Configuration["Ollama:BaseUrl"] ?? "http://localhost:11434"; - var ollamaModel = builder.Configuration["Ollama:Model"] ?? "llama3.2"; - builder.Services.AddOllamaChatClient(ollamaModel, new Uri(ollamaBaseUrl)); builder.Services.AddEasyCachingInfrastructure(builder.Configuration); builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); diff --git a/TalentManagementAPI.WebApi/TalentManagementAPI.WebApi.csproj b/TalentManagementAPI.WebApi/TalentManagementAPI.WebApi.csproj index ee84bb5..6961435 100644 --- a/TalentManagementAPI.WebApi/TalentManagementAPI.WebApi.csproj +++ b/TalentManagementAPI.WebApi/TalentManagementAPI.WebApi.csproj @@ -41,7 +41,6 @@ all - From 1645a747659e4e4ec1f9f802074ee127e441a437 Mon Sep 17 00:00:00 2001 From: Fuji Nguyen Date: Sat, 21 Mar 2026 07:50:57 -0400 Subject: [PATCH 3/3] Add AI feature --- .../Services/OllamaAiServiceTests.cs | 70 +++++++++++++++++++ .../Controllers/AiControllerTests.cs | 51 ++++++++++++++ .../Controllers/v1/AiController.cs | 14 +++- .../TalentManagementAPI.WebApi.xml | 8 +++ .../appsettings.Development.json | 1 + 5 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 TalentManagementAPI.Infrastructure.Tests/Services/OllamaAiServiceTests.cs create mode 100644 TalentManagementAPI.WebApi.Tests/Controllers/AiControllerTests.cs 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 index 4dd85bc..4c62ab4 100644 --- a/TalentManagementAPI.WebApi/Controllers/v1/AiController.cs +++ b/TalentManagementAPI.WebApi/Controllers/v1/AiController.cs @@ -1,22 +1,22 @@ using Asp.Versioning; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.FeatureManagement.Mvc; using TalentManagementAPI.Application.Interfaces; namespace TalentManagementAPI.WebApi.Controllers.v1 { - [FeatureGate("AiEnabled")] [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) + public AiController(IAiChatService aiChatService, IFeatureManagerSnapshot featureManager) { _aiChatService = aiChatService; + _featureManager = featureManager; } /// @@ -28,6 +28,14 @@ public AiController(IAiChatService aiChatService) [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)); } 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": {