From d8e6931289cb5091e97f0c7d3a196a487d3c462b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Sun, 26 Apr 2026 08:51:46 +0200 Subject: [PATCH] feat: implement AI-driven knowledge extraction service with semantic caching and persistent storage --- backlog-ai.md | 87 +++++++++++ scratch/test_normalization.cs | 25 +++ .../Services/IKnowledgeService.cs | 9 ++ .../DTOs/AI/KnowledgePacket.cs | 19 +++ .../Entities/SemanticKnowledgeCache.cs | 23 +++ .../DependencyInjection.cs | 26 +++- .../Helpers/ContentHasher.cs | 37 +++++ .../NexusReader.Infrastructure.csproj | 9 ++ .../Persistence/AppDbContext.cs | 24 +++ .../Services/KnowledgeService.cs | 145 ++++++++++++++++++ .../Services/PromptRegistry.cs | 9 ++ src/NexusReader.Web.New/Program.cs | 2 +- src/NexusReader.Web.New/appsettings.json | 11 +- 13 files changed, 423 insertions(+), 3 deletions(-) create mode 100644 backlog-ai.md create mode 100644 scratch/test_normalization.cs create mode 100644 src/NexusReader.Application/Abstractions/Services/IKnowledgeService.cs create mode 100644 src/NexusReader.Application/DTOs/AI/KnowledgePacket.cs create mode 100644 src/NexusReader.Domain/Entities/SemanticKnowledgeCache.cs create mode 100644 src/NexusReader.Infrastructure/Helpers/ContentHasher.cs create mode 100644 src/NexusReader.Infrastructure/Persistence/AppDbContext.cs create mode 100644 src/NexusReader.Infrastructure/Services/KnowledgeService.cs create mode 100644 src/NexusReader.Infrastructure/Services/PromptRegistry.cs diff --git a/backlog-ai.md b/backlog-ai.md new file mode 100644 index 0000000..1d57dd4 --- /dev/null +++ b/backlog-ai.md @@ -0,0 +1,87 @@ +# 🤖 LLM Agent Implementation Backlog: AI Semantic Integration + +**Project Context:** .NET 10, EF Core (SQLite), `Microsoft.Extensions.AI`. +**Core Goal:** Integrate Gemini 1.5 Flash with a persistent Semantic Cache to minimize API costs and latency. + +--- + +## 🏗️ Phase 1: Persistence & Domain Layer +**Objective:** Define the storage schema to prevent redundant AI calls. + +### Task 1.1: Create `SemanticKnowledgeCache` Entity +* **Target Folder:** `Core/Entities` or `Infrastructure/Persistence/Entities`. +* **Requirements:** + * Create a class `SemanticKnowledgeCache`. + * **Properties:** + * `string ContentHash` (Key, Fixed length 64). + * `string JsonData` (Required, stores the serialized AI output). + * `string ModelId` (Default: "gemini-1.5-flash"). + * `string PromptVersion` (Default: "1.0"). + * `DateTime CreatedAt` (UTC). +* **LLM Instructions:** "Generate an EF Core entity for SemanticKnowledgeCache. Ensure `ContentHash` has a Unique Index for O(1) lookups." + +### Task 1.2: Implement Hashing Utility +* **Target Folder:** `Core/Helpers` or `Infrastructure/Security`. +* **Requirements:** + * Create `ContentHasher` class. + * Method `string ComputeHash(string input)`. + * **Logic:** Normalize input (Trim, lower-case) -> Compute SHA-256 -> Return Hex string. +* **LLM Instructions:** "Create a thread-safe utility to generate SHA-256 hashes from strings. Ensure it handles nulls and whitespace consistently." + +--- + +## 🧠 Phase 2: AI Client & Contract Definition +**Objective:** Set up the communication bridge with Google Gemini API. + +### Task 2.1: Define Data Transfer Objects (DTOs) +* **Target Folder:** `Core/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. +* **Requirements:** + * Install `Microsoft.Extensions.AI` and `Microsoft.Extensions.AI.Google`. + * Register `IChatClient` using `GoogleChatClient`. + * Inject `ApiKey` from `IConfiguration`. +* **LLM Instructions:** "Register the GoogleChatClient in the DI container. Use the .NET 10 `AddChatClient` extension pattern." + +--- + +## ⚙️ Phase 3: Service Orchestration (The "Smart" Logic) +**Objective:** Implement the caching proxy logic. + +### Task 3.1: Create `KnowledgeService` Implementation +* **Target Folder:** `Application/Services`. +* **Logic Flow:** + 1. `hash = ContentHasher.ComputeHash(inputText)`. + 2. `cached = await dbContext.Cache.FirstOrDefaultAsync(h => h.ContentHash == hash)`. + 3. If `cached` exists AND `PromptVersion` matches -> Deserialize and return. + 4. Else -> Call `IChatClient.CompleteAsync(...)`. + 5. Save result to DB with the hash -> Return. +* **LLM Instructions:** "Implement a service that acts as a proxy between the UI and the Gemini API. It must prioritize SQLite cache hits over API calls." + +### Task 3.2: System Prompt Engineering +* **Requirements:** + * Create a `PromptRegistry` class. + * **System Message:** "You are an educational assistant. Analyze the text and output ONLY valid minified JSON. Schema: { 'concepts': [], 'quizzes': [] }. Do not include markdown formatting like \` \` \` json." +* **LLM Instructions:** "Craft a high-precision system prompt for Gemini 1.5 Flash to ensure it returns parseable JSON without unnecessary tokens." + +--- + +## 🛡️ Phase 4: Resilience & Optimization +**Objective:** Handle API limits and monitor performance. + +### Task 4.1: Resilience Pipeline (Polly) +* **Requirements:** + * Implement an `HttpRetry` policy specifically for `429 Too Many Requests`. + * Use Exponential Backoff with Jitter. +* **LLM Instructions:** "Add a resilience pipeline to the AI client using Polly. Handle rate-limiting gracefully to stay within the Gemini Free Tier limits." + +### Task 4.2: Request Pre-processing (Token Saving) +* **Logic:** + * Check input string length. + * If `length > threshold`, truncate or throw an error to prevent massive token spend. +* **LLM Instructions:** "Add a guard clause to the KnowledgeService to validate input size before calling the API. Log the estimated token count." diff --git a/scratch/test_normalization.cs b/scratch/test_normalization.cs new file mode 100644 index 0000000..7036985 --- /dev/null +++ b/scratch/test_normalization.cs @@ -0,0 +1,25 @@ +using System; +using System.Text.RegularExpressions; + +public class Program +{ + public static void Main() + { + string input1 = "Hello \n World"; + string input2 = "Hello World"; + + string norm1 = Normalize(input1); + string norm2 = Normalize(input2); + + Console.WriteLine($"Input 1: '{input1}' -> Normalized: '{norm1}'"); + Console.WriteLine($"Input 2: '{input2}' -> Normalized: '{norm2}'"); + Console.WriteLine($"Match: {norm1 == norm2}"); + } + + public static string Normalize(string input) + { + if (string.IsNullOrWhiteSpace(input)) return string.Empty; + var normalized = Regex.Replace(input.Trim(), @"\s+", " "); + return normalized.ToLowerInvariant(); + } +} diff --git a/src/NexusReader.Application/Abstractions/Services/IKnowledgeService.cs b/src/NexusReader.Application/Abstractions/Services/IKnowledgeService.cs new file mode 100644 index 0000000..6d97bd8 --- /dev/null +++ b/src/NexusReader.Application/Abstractions/Services/IKnowledgeService.cs @@ -0,0 +1,9 @@ +using FluentResults; +using NexusReader.Application.DTOs.AI; + +namespace NexusReader.Application.Abstractions.Services; + +public interface IKnowledgeService +{ + Task> GetKnowledgeAsync(string text, CancellationToken cancellationToken = default); +} diff --git a/src/NexusReader.Application/DTOs/AI/KnowledgePacket.cs b/src/NexusReader.Application/DTOs/AI/KnowledgePacket.cs new file mode 100644 index 0000000..0551612 --- /dev/null +++ b/src/NexusReader.Application/DTOs/AI/KnowledgePacket.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace NexusReader.Application.DTOs.AI; + +public record KeyConcept( + [property: JsonPropertyName("title")] string Title, + [property: JsonPropertyName("description")] string Description +); + +public record QuizQuestion( + [property: JsonPropertyName("question")] string Question, + [property: JsonPropertyName("options")] List Options, + [property: JsonPropertyName("correct_index")] int CorrectIndex +); + +public record KnowledgePacket( + [property: JsonPropertyName("concepts")] List Concepts, + [property: JsonPropertyName("quizzes")] List Quizzes +); diff --git a/src/NexusReader.Domain/Entities/SemanticKnowledgeCache.cs b/src/NexusReader.Domain/Entities/SemanticKnowledgeCache.cs new file mode 100644 index 0000000..1c0d9b9 --- /dev/null +++ b/src/NexusReader.Domain/Entities/SemanticKnowledgeCache.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace NexusReader.Domain.Entities; + +public class SemanticKnowledgeCache +{ + [Key] + [MaxLength(64)] + public string ContentHash { get; set; } = string.Empty; + + [Required] + public string JsonData { get; set; } = string.Empty; + + [Required] + [MaxLength(50)] + public string ModelId { get; set; } = "gemini-1.5-flash"; + + [Required] + [MaxLength(10)] + public string PromptVersion { get; set; } = "1.0"; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/NexusReader.Infrastructure/DependencyInjection.cs b/src/NexusReader.Infrastructure/DependencyInjection.cs index 4a41272..486702d 100644 --- a/src/NexusReader.Infrastructure/DependencyInjection.cs +++ b/src/NexusReader.Infrastructure/DependencyInjection.cs @@ -1,4 +1,10 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Configuration; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.AI; +using GeminiDotnet; +using GeminiDotnet.Extensions.AI; +using NexusReader.Infrastructure.Persistence; using NexusReader.Application.Abstractions.Services; using NexusReader.Infrastructure.Services; @@ -6,8 +12,26 @@ namespace NexusReader.Infrastructure; public static class DependencyInjection { - public static IServiceCollection AddInfrastructure(this IServiceCollection services) + public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) { + var connectionString = configuration.GetConnectionString("SqliteConnection") ?? "Data Source=nexus.db"; + 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.AddSingleton(new GeminiChatClient(new GeminiClientOptions + { + ApiKey = apiKey, + ModelId = modelId + })); + + services.AddScoped(); services.AddTransient(); services.AddTransient(); return services; diff --git a/src/NexusReader.Infrastructure/Helpers/ContentHasher.cs b/src/NexusReader.Infrastructure/Helpers/ContentHasher.cs new file mode 100644 index 0000000..03a6407 --- /dev/null +++ b/src/NexusReader.Infrastructure/Helpers/ContentHasher.cs @@ -0,0 +1,37 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; + +namespace NexusReader.Infrastructure.Helpers; + +public static class ContentHasher +{ + public static string ComputeHash(string input) + { + if (string.IsNullOrWhiteSpace(input)) + { + return string.Empty; + } + + var normalizedInput = Normalize(input); + + var inputBytes = Encoding.UTF8.GetBytes(normalizedInput); + var hashBytes = SHA256.HashData(inputBytes); + + return Convert.ToHexString(hashBytes).ToLowerInvariant(); + } + + public static string Normalize(string input) + { + if (string.IsNullOrWhiteSpace(input)) + { + return string.Empty; + } + + // Trim and collapse all consecutive whitespace characters into a single space + var normalized = Regex.Replace(input.Trim(), @"\s+", " "); + + // Convert to lower-case as AI analysis is generally not case-sensitive for concepts/quizzes + return normalized.ToLowerInvariant(); + } +} diff --git a/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj b/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj index efc1082..e48c337 100644 --- a/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj +++ b/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj @@ -5,6 +5,15 @@ + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + diff --git a/src/NexusReader.Infrastructure/Persistence/AppDbContext.cs b/src/NexusReader.Infrastructure/Persistence/AppDbContext.cs new file mode 100644 index 0000000..f2a7416 --- /dev/null +++ b/src/NexusReader.Infrastructure/Persistence/AppDbContext.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using NexusReader.Domain.Entities; + +namespace NexusReader.Infrastructure.Persistence; + +public class AppDbContext : DbContext +{ + public AppDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet SemanticKnowledgeCache => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.ContentHash); + entity.HasIndex(e => e.ContentHash).IsUnique(); + }); + } +} diff --git a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs new file mode 100644 index 0000000..ac44202 --- /dev/null +++ b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs @@ -0,0 +1,145 @@ +using System.Text.Json; +using FluentResults; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.AI; +using NexusReader.Application.Abstractions.Services; +using NexusReader.Application.DTOs.AI; +using NexusReader.Domain.Entities; +using NexusReader.Infrastructure.Helpers; +using NexusReader.Infrastructure.Persistence; +using Polly; +using Polly.Retry; + +namespace NexusReader.Infrastructure.Services; + +public class KnowledgeService : IKnowledgeService +{ + private readonly IChatClient _chatClient; + private readonly AppDbContext _dbContext; + 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) + { + _chatClient = chatClient; + _dbContext = dbContext; + } + + public async Task> GetKnowledgeAsync(string text, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(text)) + { + return Result.Fail("Input text is empty."); + } + + // Normalize text to ensure consistent hashing and reduce token noise + var normalizedText = ContentHasher.Normalize(text); + + // Phase 4: Request Pre-processing (Token Saving) + const int MaxInputLength = 15000; // Roughly 3k-4k tokens + if (normalizedText.Length > MaxInputLength) + { + return Result.Fail($"Input text is too long ({normalizedText.Length} characters after normalization). Max allowed is {MaxInputLength}."); + } + + // Simple token estimation (4 chars per token) + var estimatedTokens = normalizedText.Length / 4; + Console.WriteLine($"[KnowledgeService] Processing request with ~{estimatedTokens} tokens."); + + var hash = ContentHasher.ComputeHash(normalizedText); + + // 1. Check Cache + var cached = await _dbContext.SemanticKnowledgeCache + .FirstOrDefaultAsync(c => c.ContentHash == hash && c.PromptVersion == PromptVersion, cancellationToken); + + if (cached != null) + { + try + { + var packet = JsonSerializer.Deserialize(cached.JsonData); + if (packet != null) + { + return Result.Ok(packet); + } + } + catch (JsonException) + { + // If deserialization fails, we proceed to call the AI + } + } + + // 2. Call AI Client + try + { + var options = new ChatOptions + { + ResponseFormat = ChatResponseFormat.Json, + Temperature = 0.1f, + MaxOutputTokens = 1000 + }; + + var response = await _retryPipeline.ExecuteAsync(async ct => + await _chatClient.GetResponseAsync(new List + { + new ChatMessage(ChatRole.System, PromptRegistry.KnowledgeExtractionSystemPrompt), + new ChatMessage(ChatRole.User, normalizedText) + }, options, cancellationToken: ct), cancellationToken); + + var jsonResponse = response.Text; + if (string.IsNullOrWhiteSpace(jsonResponse)) + { + return Result.Fail("AI returned an empty response."); + } + + // Cleanup potential markdown if Gemini still adds it despite options + jsonResponse = jsonResponse.Replace("```json", "").Replace("```", "").Trim(); + + var knowledgePacket = JsonSerializer.Deserialize(jsonResponse); + if (knowledgePacket == null) + { + return Result.Fail("Failed to deserialize AI response."); + } + + // 3. Save to Cache + var cacheEntry = new SemanticKnowledgeCache + { + ContentHash = hash, + JsonData = jsonResponse, + ModelId = ModelId, + PromptVersion = PromptVersion, + CreatedAt = DateTime.UtcNow + }; + + // Handle potential race condition if multiple requests for same text arrive + if (cached == null) + { + _dbContext.SemanticKnowledgeCache.Add(cacheEntry); + } + else + { + cached.JsonData = jsonResponse; + cached.CreatedAt = DateTime.UtcNow; + } + + await _dbContext.SaveChangesAsync(cancellationToken); + + return Result.Ok(knowledgePacket); + } + catch (Exception ex) + { + return Result.Fail(new Error("Failed to extract knowledge from AI").CausedBy(ex)); + } + } +} diff --git a/src/NexusReader.Infrastructure/Services/PromptRegistry.cs b/src/NexusReader.Infrastructure/Services/PromptRegistry.cs new file mode 100644 index 0000000..770e91c --- /dev/null +++ b/src/NexusReader.Infrastructure/Services/PromptRegistry.cs @@ -0,0 +1,9 @@ +namespace NexusReader.Infrastructure.Services; + +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. " + + "Schema: { \"concepts\": [ { \"title\": \"string\", \"description\": \"string\" } ], \"quizzes\": [ { \"question\": \"string\", \"options\": [ \"string\" ], \"correct_index\": 0 } ] }."; +} diff --git a/src/NexusReader.Web.New/Program.cs b/src/NexusReader.Web.New/Program.cs index 07e2cc7..db02d0d 100644 --- a/src/NexusReader.Web.New/Program.cs +++ b/src/NexusReader.Web.New/Program.cs @@ -26,7 +26,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddApplication(); -builder.Services.AddInfrastructure(); +builder.Services.AddInfrastructure(builder.Configuration); var app = builder.Build(); diff --git a/src/NexusReader.Web.New/appsettings.json b/src/NexusReader.Web.New/appsettings.json index 4d56694..32a333c 100644 --- a/src/NexusReader.Web.New/appsettings.json +++ b/src/NexusReader.Web.New/appsettings.json @@ -5,5 +5,14 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "ConnectionStrings": { + "SqliteConnection": "Data Source=nexus.db" + }, + "Ai": { + "Google": { + "ApiKey": "PLACEHOLDER", + "Model": "gemini-1.5-flash" + } + } }