diff --git a/backlog-ai.md b/backlog-ai.md index 1d57dd4..44e13e8 100644 --- a/backlog-ai.md +++ b/backlog-ai.md @@ -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` and `List`. * 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." --- diff --git a/src/NexusReader.Infrastructure/Configuration/AiSettings.cs b/src/NexusReader.Infrastructure/Configuration/AiSettings.cs new file mode 100644 index 0000000..5678338 --- /dev/null +++ b/src/NexusReader.Infrastructure/Configuration/AiSettings.cs @@ -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; +} diff --git a/src/NexusReader.Infrastructure/DependencyInjection.cs b/src/NexusReader.Infrastructure/DependencyInjection.cs index 486702d..a9ae87b 100644 --- a/src/NexusReader.Infrastructure/DependencyInjection.cs +++ b/src/NexusReader.Infrastructure/DependencyInjection.cs @@ -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(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(configuration.GetSection(AiSettings.SectionName)); + var aiSettings = configuration.GetSection(AiSettings.SectionName).Get() ?? new AiSettings(); - services.AddSingleton(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(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(); diff --git a/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj b/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj index e48c337..423f29f 100644 --- a/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj +++ b/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj @@ -12,6 +12,7 @@ + diff --git a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs index ac44202..41f34b3 100644 --- a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs +++ b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs @@ -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(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 pipelineProvider, + IOptions settings) { _chatClient = chatClient; _dbContext = dbContext; + _retryPipeline = pipelineProvider.GetPipeline("ai-retry"); + _settings = settings.Value; } public async Task> 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 }; diff --git a/src/NexusReader.Infrastructure/Services/PromptRegistry.cs b/src/NexusReader.Infrastructure/Services/PromptRegistry.cs index 770e91c..628396e 100644 --- a/src/NexusReader.Infrastructure/Services/PromptRegistry.cs +++ b/src/NexusReader.Infrastructure/Services/PromptRegistry.cs @@ -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 } ] }."; }