feat: externalize AI configuration, implement resilience policies, and update extraction prompt formatting
This commit is contained in:
+7
-7
@@ -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")
|
||||
{
|
||||
// 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 = apiKey,
|
||||
ModelId = modelId
|
||||
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 } ] }.";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user