feat: externalize AI configuration, implement resilience policies, and update extraction prompt formatting

This commit is contained in:
2026-04-26 10:01:47 +02:00
parent d8e6931289
commit 412320980f
6 changed files with 65 additions and 38 deletions
+7 -7
View File
@@ -1,6 +1,6 @@
# 🤖 LLM Agent Implementation Backlog: AI Semantic Integration
**Project Context:** .NET 10, EF Core (SQLite), `Microsoft.Extensions.AI`.
**Project Context:** .NET 10, EF Core (SQLite), `Microsoft.Extensions.AI`, [`GeminiDotnet`](https://github.com/rabuckley/GeminiDotnet).
**Core Goal:** Integrate Gemini 1.5 Flash with a persistent Semantic Cache to minimize API costs and latency.
---
@@ -31,22 +31,22 @@
---
## 🧠 Phase 2: AI Client & Contract Definition
**Objective:** Set up the communication bridge with Google Gemini API.
**Objective:** Set up the communication bridge with Google Gemini API using [`GeminiDotnet`](https://github.com/rabuckley/GeminiDotnet).
### Task 2.1: Define Data Transfer Objects (DTOs)
* **Target Folder:** `Core/DTOs/AI`.
* **Target Folder:** `NexusReader.Application/DTOs/AI`.
* **Requirements:**
* Define `KnowledgePacket` record containing `List<KeyConcept>` and `List<QuizQuestion>`.
* Use `[JsonPropertyName]` attributes for strict JSON mapping.
* **LLM Instructions:** "Define immutable records for the AI response schema. Ensure they match the expected JSON structure from the system prompt."
### Task 2.2: Infrastructure AI Client Setup
* **Target:** `Program.cs` / Dependency Injection.
* **Target:** `NexusReader.Infrastructure/DependencyInjection.cs`.
* **Requirements:**
* Install `Microsoft.Extensions.AI` and `Microsoft.Extensions.AI.Google`.
* Register `IChatClient` using `GoogleChatClient`.
* Install `Microsoft.Extensions.AI` and `GeminiDotnet.Extensions.AI`.
* Register `IChatClient` using `GeminiChatClient`.
* Inject `ApiKey` from `IConfiguration`.
* **LLM Instructions:** "Register the GoogleChatClient in the DI container. Use the .NET 10 `AddChatClient` extension pattern."
* **LLM Instructions:** "Register the `GeminiChatClient` in the DI container. Use the .NET 10 `AddChatClient` extension pattern."
---
@@ -0,0 +1,13 @@
namespace NexusReader.Infrastructure.Configuration;
public class AiSettings
{
public const string SectionName = "Ai:Google";
public string ApiKey { get; set; } = string.Empty;
public string Model { get; set; } = "gemini-1.5-flash";
public int MaxInputLength { get; set; } = 15000;
public int MaxOutputTokens { get; set; } = 1000;
public int RetryAttempts { get; set; } = 3;
public double Temperature { get; set; } = 0.1;
}
@@ -7,6 +7,9 @@ using GeminiDotnet.Extensions.AI;
using NexusReader.Infrastructure.Persistence;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Infrastructure.Services;
using NexusReader.Infrastructure.Configuration;
using Polly;
using Polly.Retry;
namespace NexusReader.Infrastructure;
@@ -18,17 +21,31 @@ public static class DependencyInjection
services.AddDbContext<AppDbContext>(options =>
options.UseSqlite(connectionString));
var apiKey = configuration["Ai:Google:ApiKey"];
if (string.IsNullOrWhiteSpace(apiKey))
{
throw new InvalidOperationException("AI Studio ApiKey is missing in configuration (Ai:Google:ApiKey).");
}
var modelId = configuration["Ai:Google:Model"] ?? "gemini-1.5-flash";
services.Configure<AiSettings>(configuration.GetSection(AiSettings.SectionName));
var aiSettings = configuration.GetSection(AiSettings.SectionName).Get<AiSettings>() ?? new AiSettings();
services.AddSingleton<IChatClient>(new GeminiChatClient(new GeminiClientOptions
if (string.IsNullOrWhiteSpace(aiSettings.ApiKey) || aiSettings.ApiKey == "PLACEHOLDER")
{
ApiKey = apiKey,
ModelId = modelId
// We don't throw here to allow the app to start, but services using AI will fail gracefully
}
services.AddResiliencePipeline("ai-retry", builder =>
{
builder.AddRetry(new RetryStrategyOptions
{
ShouldHandle = new PredicateBuilder().Handle<Exception>(ex =>
ex.Message.Contains("429") || ex.Message.Contains("Too Many Requests") || ex.Message.Contains("quota")),
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
MaxRetryAttempts = aiSettings.RetryAttempts,
Delay = TimeSpan.FromSeconds(2)
});
});
services.AddChatClient(new GeminiChatClient(new GeminiClientOptions
{
ApiKey = aiSettings.ApiKey,
ModelId = aiSettings.Model
}));
services.AddScoped<IKnowledgeService, KnowledgeService>();
@@ -12,6 +12,7 @@
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.AI" Version="10.5.0" />
<PackageReference Include="Microsoft.Extensions.Resilience" Version="10.5.0" />
<PackageReference Include="Polly" Version="8.6.6" />
<PackageReference Include="Polly.Extensions.Http" Version="3.0.0" />
<PackageReference Include="VersOne.Epub" Version="3.3.6" />
@@ -8,7 +8,9 @@ using NexusReader.Domain.Entities;
using NexusReader.Infrastructure.Helpers;
using NexusReader.Infrastructure.Persistence;
using Polly;
using Polly.Retry;
using Polly.Registry;
using Microsoft.Extensions.Options;
using NexusReader.Infrastructure.Configuration;
namespace NexusReader.Infrastructure.Services;
@@ -16,25 +18,20 @@ public class KnowledgeService : IKnowledgeService
{
private readonly IChatClient _chatClient;
private readonly AppDbContext _dbContext;
private readonly ResiliencePipeline _retryPipeline;
private readonly AiSettings _settings;
private const string PromptVersion = "1.0";
private const string ModelId = "gemini-1.5-flash";
private static readonly ResiliencePipeline _retryPipeline = new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions
{
ShouldHandle = new PredicateBuilder().Handle<Exception>(ex =>
ex.Message.Contains("429") || ex.Message.Contains("Too Many Requests") || ex.Message.Contains("quota")),
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
MaxRetryAttempts = 3,
Delay = TimeSpan.FromSeconds(2)
})
.Build();
public KnowledgeService(IChatClient chatClient, AppDbContext dbContext)
public KnowledgeService(
IChatClient chatClient,
AppDbContext dbContext,
ResiliencePipelineProvider<string> pipelineProvider,
IOptions<AiSettings> settings)
{
_chatClient = chatClient;
_dbContext = dbContext;
_retryPipeline = pipelineProvider.GetPipeline("ai-retry");
_settings = settings.Value;
}
public async Task<Result<KnowledgePacket>> GetKnowledgeAsync(string text, CancellationToken cancellationToken = default)
@@ -48,10 +45,9 @@ public class KnowledgeService : IKnowledgeService
var normalizedText = ContentHasher.Normalize(text);
// Phase 4: Request Pre-processing (Token Saving)
const int MaxInputLength = 15000; // Roughly 3k-4k tokens
if (normalizedText.Length > MaxInputLength)
if (normalizedText.Length > _settings.MaxInputLength)
{
return Result.Fail($"Input text is too long ({normalizedText.Length} characters after normalization). Max allowed is {MaxInputLength}.");
return Result.Fail($"Input text is too long ({normalizedText.Length} characters after normalization). Max allowed is {_settings.MaxInputLength}.");
}
// Simple token estimation (4 chars per token)
@@ -86,8 +82,8 @@ public class KnowledgeService : IKnowledgeService
var options = new ChatOptions
{
ResponseFormat = ChatResponseFormat.Json,
Temperature = 0.1f,
MaxOutputTokens = 1000
Temperature = (float)_settings.Temperature,
MaxOutputTokens = _settings.MaxOutputTokens
};
var response = await _retryPipeline.ExecuteAsync(async ct =>
@@ -117,7 +113,7 @@ public class KnowledgeService : IKnowledgeService
{
ContentHash = hash,
JsonData = jsonResponse,
ModelId = ModelId,
ModelId = _settings.Model,
PromptVersion = PromptVersion,
CreatedAt = DateTime.UtcNow
};
@@ -4,6 +4,6 @@ public static class PromptRegistry
{
public const string KnowledgeExtractionSystemPrompt =
"You are an expert educator. Analyze the provided text to extract key concepts and generate relevant quizzes. " +
"Return ONLY a minified JSON object that strictly adheres to the provided schema. No markdown formatting, no explanations. " +
"CRITICAL: Return ONLY a minified JSON object. Do NOT include markdown formatting like ```json or ```. Do NOT include explanations. " +
"Schema: { \"concepts\": [ { \"title\": \"string\", \"description\": \"string\" } ], \"quizzes\": [ { \"question\": \"string\", \"options\": [ \"string\" ], \"correct_index\": 0 } ] }.";
}