Skip to content

Commit cb81930

Browse files
add blogs
1 parent 6b80d70 commit cb81930

7 files changed

Lines changed: 107 additions & 46 deletions

File tree

.claude/settings.local.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
"Bash(node -e \":*)",
66
"Bash(git add blogs/series-2-dotnet-api/2.1-dotnet-clean-architecture.md)",
77
"Bash(git commit -m \"Add brief EasyCaching mention to 2.1 clean architecture article\")",
8-
"Bash(git push)"
8+
"Bash(git push)",
9+
"Bash(gh pr:*)"
910
],
1011
"deny": [],
1112
"ask": []

blogs/series-6-ai-app-features/6.1-dotnet-ai-foundation.md

Lines changed: 95 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -90,21 +90,15 @@ curl http://localhost:11434/api/tags
9090

9191
### Step 2: Add NuGet Packages
9292

93-
The AI packages split across two projects to maintain Clean Architecture separation:
93+
Add OllamaSharp to the Infrastructure.Shared project — this is the only AI package needed:
9494

95-
**`TalentManagementAPI.WebApi.csproj`** — The Ollama provider lives here:
95+
**`TalentManagementAPI.Infrastructure.Shared.csproj`**:
9696

9797
```xml
98-
<PackageReference Include="Microsoft.Extensions.AI.Ollama" Version="9.5.0" />
98+
<PackageReference Include="OllamaSharp" Version="5.3.4" />
9999
```
100100

101-
**`TalentManagementAPI.Infrastructure.Shared.csproj`** — The abstraction lives here:
102-
103-
```xml
104-
<PackageReference Include="Microsoft.Extensions.AI" Version="9.5.0" />
105-
```
106-
107-
**Why split?** The Application and Infrastructure layers must never reference provider-specific packages (`Microsoft.Extensions.AI.Ollama`). Only WebApi knows which provider is registered. Infrastructure.Shared only knows about `IChatClient` from `Microsoft.Extensions.AI`.
101+
**Why OllamaSharp instead of `Microsoft.Extensions.AI`?** OllamaSharp provides native streaming support via `IAsyncEnumerable<>` (`await foreach`), so tokens stream out of Ollama as they are generated — important when local model responses take several seconds. `Microsoft.Extensions.AI` is the right abstraction when you need to swap between Azure OpenAI, OpenAI, and Ollama without touching service code. For this tutorial, OllamaSharp keeps the dependency footprint minimal: one package, only in Infrastructure.Shared, no provider registration boilerplate in Program.cs.
108102

109103
### Step 3: Add Feature Flag and Ollama Config
110104

@@ -118,10 +112,19 @@ In `TalentManagementAPI.WebApi/appsettings.json`, add `AiEnabled` to the existin
118112
},
119113
"Ollama": {
120114
"BaseUrl": "http://localhost:11434",
121-
"Model": "llama3.2"
115+
"Model": "llama3.2",
116+
"EmbeddingModel": "nomic-embed-text",
117+
"CacheTtlMinutes": 60
122118
}
123119
```
124120

121+
**What each field does:**
122+
123+
* **`BaseUrl`** — where Ollama is listening (`ollama serve` defaults to port 11434)
124+
* **`Model`** — the chat model to use; `llama3.2` is pulled in Step 1
125+
* **`EmbeddingModel`** — used in later articles (6.5+) for semantic search; `nomic-embed-text` is a compact, high-quality embedding model
126+
* **`CacheTtlMinutes`** — how long AI responses are cached in-memory; identical questions within this window return instantly without hitting Ollama again (introduced in the `CachingAiChatService` below)
127+
125128
**Key point:** `"AiEnabled": false` is the default. Developers who haven't installed Ollama can still clone and run the full stack — the AI endpoint simply returns 404. To activate AI features, change this to `true` and ensure Ollama is running.
126129

127130
### Step 4: Define the Application Interface
@@ -146,73 +149,102 @@ namespace TalentManagementAPI.Application.Interfaces
146149
Create `TalentManagementAPI.Infrastructure.Shared/Services/OllamaAiService.cs`:
147150

148151
```csharp
149-
using Microsoft.Extensions.AI;
150152
using TalentManagementAPI.Application.Interfaces;
151153

152154
namespace TalentManagementAPI.Infrastructure.Shared.Services
153155
{
154156
public class OllamaAiService : IAiChatService
155157
{
156-
private readonly IChatClient _chatClient;
158+
private readonly IOllamaApiClient _ollamaApiClient;
157159

158-
public OllamaAiService(IChatClient chatClient)
160+
public OllamaAiService(IOllamaApiClient ollamaApiClient)
159161
{
160-
_chatClient = chatClient;
162+
_ollamaApiClient = ollamaApiClient;
161163
}
162164

163165
public async Task<string> ChatAsync(string message, string? systemPrompt = null,
164166
CancellationToken cancellationToken = default)
165167
{
166-
var messages = new List<ChatMessage>();
168+
var messages = new List<Message>();
167169

168170
if (!string.IsNullOrWhiteSpace(systemPrompt))
169-
messages.Add(new ChatMessage(ChatRole.System, systemPrompt));
171+
messages.Add(new Message(new ChatRole("system"), systemPrompt));
172+
173+
messages.Add(new Message(new ChatRole("user"), message));
174+
175+
var request = new ChatRequest
176+
{
177+
Model = _ollamaApiClient.SelectedModel,
178+
Messages = messages,
179+
Stream = true
180+
};
181+
182+
var responseBuilder = new MessageBuilder();
170183

171-
messages.Add(new ChatMessage(ChatRole.User, message));
184+
await foreach (var response in _ollamaApiClient.ChatAsync(request, cancellationToken)
185+
.WithCancellation(cancellationToken))
186+
{
187+
if (response?.Message is not null)
188+
responseBuilder.Append(response);
189+
}
172190

173-
var response = await _chatClient.CompleteAsync(messages, cancellationToken: cancellationToken);
174-
return response.Message.Text ?? string.Empty;
191+
return responseBuilder.HasValue
192+
? responseBuilder.ToMessage().Content ?? string.Empty
193+
: string.Empty;
175194
}
176195
}
177196
}
178197
```
179198

180-
**What this does:** `OllamaAiService` takes `IChatClient` from DI — it has no idea it's talking to Ollama specifically. The `CompleteAsync` method sends the message list and returns the model's reply. An optional system prompt lets callers control the AI's persona or constraints.
199+
**What this does:** `OllamaAiService` takes `IOllamaApiClient` from DI (registered in Step 6). OllamaSharp streams tokens back using `IAsyncEnumerable<>` — the `await foreach` loop accumulates each chunk into a `MessageBuilder`, then returns the fully assembled reply. An optional system prompt lets callers control the AI's persona or constraints without the service knowing anything about the caller's intent.
181200

182201
### Step 6: Register Services
183202

184-
In `Infrastructure.Shared/ServiceRegistration.cs`, add the `IAiChatService` `OllamaAiService` binding:
203+
In `Infrastructure.Shared/ServiceRegistration.cs`, register `IOllamaApiClient` and wire `IAiChatService` to a caching decorator that wraps `OllamaAiService`:
185204

186205
```csharp
187206
using TalentManagementAPI.Application.Interfaces;
188207
using TalentManagementAPI.Infrastructure.Shared.Services;
189208

190-
public static void AddSharedInfrastructure(this IServiceCollection services, IConfiguration _config)
209+
public static void AddSharedInfrastructure(this IServiceCollection services, IConfiguration config)
191210
{
192-
services.Configure<MailSettings>(_config.GetSection("MailSettings"));
211+
services.Configure<MailSettings>(config.GetSection("MailSettings"));
193212
services.AddTransient<IDateTimeService, DateTimeService>();
194213
services.AddTransient<IEmailService, EmailService>();
195214
services.AddTransient<IMockService, MockService>();
196-
services.AddTransient<IAiChatService, OllamaAiService>();
215+
216+
// Register the Ollama client as a singleton — one connection reused across requests
217+
services.AddSingleton<IOllamaApiClient>(_ =>
218+
{
219+
var baseUrl = config["Ollama:BaseUrl"] ?? "http://localhost:11434";
220+
var model = config["Ollama:Model"] ?? "llama3.2";
221+
return new OllamaApiClient(new Uri(baseUrl), model);
222+
});
223+
224+
// Metadata scoped per-request so the controller can read cache hit/miss
225+
services.AddScoped<IAiResponseMetadata, AiResponseMetadata>();
226+
227+
// Wrap OllamaAiService with a caching decorator — identical questions within
228+
// CacheTtlMinutes return instantly without hitting Ollama again
229+
var ttlMinutes = config.GetValue<int>("Ollama:CacheTtlMinutes", 60);
230+
services.AddTransient<OllamaAiService>();
231+
services.AddTransient<IAiChatService>(sp => new CachingAiChatService(
232+
sp.GetRequiredService<OllamaAiService>(),
233+
sp.GetRequiredService<ICacheProvider>(),
234+
sp.GetRequiredService<IAiResponseMetadata>(),
235+
TimeSpan.FromMinutes(ttlMinutes)));
197236
}
198237
```
199238

200-
In `WebApi/Program.cs`, register the Ollama provider for `IChatClient`:
239+
In `WebApi/Program.cs`, the only AI-related line is the call to `AddSharedInfrastructure` — no extra registration needed:
201240

202241
```csharp
203-
// Register application services
204242
builder.Services.AddApplicationLayer();
205243
builder.Services.AddPersistenceInfrastructure(builder.Configuration);
206-
builder.Services.AddSharedInfrastructure(builder.Configuration);
207-
208-
// Register Ollama chat client (IChatClient) — used by OllamaAiService
209-
// AiController is gated by [FeatureGate("AiEnabled")], so no calls are made when AI is disabled
210-
var ollamaBaseUrl = builder.Configuration["Ollama:BaseUrl"] ?? "http://localhost:11434";
211-
var ollamaModel = builder.Configuration["Ollama:Model"] ?? "llama3.2";
212-
builder.Services.AddOllamaChatClient(ollamaModel, new Uri(ollamaBaseUrl));
244+
builder.Services.AddSharedInfrastructure(builder.Configuration); // ← registers IOllamaApiClient + IAiChatService
213245
```
214246

215-
**What this does:** `AddOllamaChatClient()` registers `IChatClient` in the DI container pointing to Ollama. `OllamaAiService` receives this via constructor injection. If you later want to use Azure OpenAI, you'd replace `AddOllamaChatClient()` with `AddAzureOpenAIChatClient()` — and nothing else changes.
247+
**What the caching decorator does:** `CachingAiChatService` wraps `OllamaAiService`. On the first call for a given `(message, systemPrompt)` pair, it calls Ollama and stores the reply. On subsequent identical calls within the TTL window, it returns the cached reply — skipping the 1–4 second Ollama inference. The `IAiResponseMetadata` flag tells the controller whether the response was a cache hit, which is surfaced as the `X-AI-Cache: HIT/MISS` response header.
216248

217249
### Step 7: Create the AI Controller
218250

@@ -222,33 +254,50 @@ Create `TalentManagementAPI.WebApi/Controllers/v1/AiController.cs`:
222254
using Asp.Versioning;
223255
using Microsoft.AspNetCore.Authorization;
224256
using Microsoft.AspNetCore.Mvc;
225-
using Microsoft.FeatureManagement.Mvc;
226257
using TalentManagementAPI.Application.Interfaces;
227258

228259
namespace TalentManagementAPI.WebApi.Controllers.v1
229260
{
230-
[FeatureGate("AiEnabled")]
231261
[ApiVersion("1.0")]
232262
[AllowAnonymous]
233263
[Route("api/v{version:apiVersion}/ai")]
234264
public sealed class AiController : BaseApiController
235265
{
236266
private readonly IAiChatService _aiChatService;
267+
private readonly IFeatureManagerSnapshot _featureManager;
268+
private readonly IAiResponseMetadata _aiMetadata;
237269

238-
public AiController(IAiChatService aiChatService)
270+
public AiController(
271+
IAiChatService aiChatService,
272+
IFeatureManagerSnapshot featureManager,
273+
IAiResponseMetadata aiMetadata)
239274
{
240275
_aiChatService = aiChatService;
276+
_featureManager = featureManager;
277+
_aiMetadata = aiMetadata;
241278
}
242279

280+
private void SetAiCacheHeader()
281+
=> Response.Headers["X-AI-Cache"] = _aiMetadata.WasCacheHit ? "HIT" : "MISS";
282+
243283
/// <summary>
244284
/// Send a message to the AI assistant and receive a reply.
245285
/// </summary>
246286
[HttpPost("chat")]
247287
public async Task<IActionResult> Chat([FromBody] AiChatRequest request,
248288
CancellationToken cancellationToken)
249289
{
290+
if (!await _featureManager.IsEnabledAsync("AiEnabled"))
291+
{
292+
return Problem(
293+
detail: "AI chat is disabled. Enable FeatureManagement:AiEnabled to use this endpoint.",
294+
title: "AI chat is disabled",
295+
statusCode: StatusCodes.Status503ServiceUnavailable);
296+
}
297+
250298
var reply = await _aiChatService.ChatAsync(
251299
request.Message, request.SystemPrompt, cancellationToken);
300+
SetAiCacheHeader();
252301
return Ok(new AiChatResponse(reply));
253302
}
254303
}
@@ -258,9 +307,15 @@ namespace TalentManagementAPI.WebApi.Controllers.v1
258307
}
259308
```
260309

261-
**What `[FeatureGate("AiEnabled")]` does:** When `AiEnabled` is `false` in `appsettings.json`, ASP.NET Core returns a `404 Not Found` for all routes under this controller. Ollama is never called. The controller doesn't appear in Swagger. To the rest of the app, it doesn't exist.
310+
**Why per-method checks instead of `[FeatureGate]` on the class?**
311+
312+
The `[FeatureGate("AiEnabled")]` attribute returns `404 Not Found` when the feature is disabled — a misleading status for a known endpoint. The per-method check returns `503 Service Unavailable` with a clear `detail` message explaining exactly what to enable and where. This is far more helpful to developers hitting the endpoint for the first time.
313+
314+
**`IFeatureManagerSnapshot`** — the snapshot variant reads the feature flags once per request and caches the result for the request lifetime. This avoids multiple config reads per action.
315+
316+
**`IAiResponseMetadata`** — a scoped flag (set by `CachingAiChatService` in Step 6) that records whether the response came from the cache. `SetAiCacheHeader()` surfaces this as `X-AI-Cache: HIT` or `MISS` in every response — visible in the browser Network tab and Swagger, making it easy to see when caching is working.
262317

263-
When `AiEnabled` is `true`, the endpoint becomes fully active. No other code changes needed.
318+
When `AiEnabled` is `true`, the endpoint is fully active. No other code changes needed.
264319

265320
---
266321

blogs/series-6-ai-app-features/6.2-dotnet-ai-hr-assistant.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,12 +217,12 @@ namespace TalentManagementAPI.Application.Features.AI.Queries.GetHrInsight
217217

218218
### Step 3: Add the Controller Endpoint
219219

220-
In `TalentManagementAPI.WebApi/Controllers/v1/AiController.cs`, add the `hr-insight` endpoint and request record:
220+
In `TalentManagementAPI.WebApi/Controllers/v1/AiController.cs`, add the `hr-insight` action. This builds on the controller created in Article 6.1 — `_featureManager` (`IFeatureManagerSnapshot`) and `_aiMetadata` (`IAiResponseMetadata`) are already injected in the constructor alongside `IAiChatService`. The `hr-insight` action follows the exact same per-method feature flag pattern as `chat`.
221221

222222
```csharp
223223
using TalentManagementAPI.Application.Features.AI.Queries.GetHrInsight;
224224

225-
// Inside AiController:
225+
// Inside AiController (add after the Chat action):
226226
227227
/// <summary>
228228
/// Ask the HR AI assistant a question about your current workforce data.
@@ -245,6 +245,7 @@ public async Task<IActionResult> HrInsight(
245245
new GetHrInsightQuery { Question = request.Question },
246246
cancellationToken);
247247

248+
SetAiCacheHeader();
248249
return Ok(result);
249250
}
250251

blogs/series-6-ai-app-features/6.3-angular-ai-chat-widget.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ export const environment = {
8282

8383
Add the same `aiEnabled: false` line under `// Feature Flags`. Production defaults to off.
8484

85+
> **Note for repo cloners:** If you cloned the tutorial repo, `environment.ts` may already have `aiEnabled: true` (set during development of later articles). To follow this article from scratch — seeing the disabled state first — flip it to `false`, then back to `true` when you're ready to test the chat UI.
86+
8587
**Why default to false?** Readers who are following the original Series 0–5 tutorial don't have Ollama running. If the chat widget made API calls with `AiEnabled: false` in the API, every request would return `503 Service Unavailable` — a broken experience. Defaulting the flag to `false` in the Angular environment means the chat UI is never shown to those readers, so their app continues to work exactly as it did before.
8688

8789
---
@@ -150,6 +152,8 @@ export * from './base-api.service';
150152
// ... existing exports
151153
```
152154

155+
> **Note:** The `ai.service.ts` file shown here covers the two methods needed for Articles 6.3 and 6.4 (`chat` and `hrInsight`). Later articles (6.5+) add `nlEmployeeSearch()` and `semanticPositionSearch()` to the same service file. If you clone the repo, you will see those additional methods — they are safe to ignore until you reach those articles.
156+
153157
---
154158

155159
### Step 3: Create the Chat Component

0 commit comments

Comments
 (0)