From 7859c9806fc751d48931a9e8bea4d82ab063bb50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Sun, 26 Apr 2026 14:53:48 +0200 Subject: [PATCH] feat: implement dynamic knowledge graph updates and state management services --- .../Services/IKnowledgeService.cs | 1 + .../DTOs/AI/KnowledgePacket.cs | 10 +- .../Graph/GetKnowledgeGraphQueryHandler.cs | 21 +- .../Queries/Graph/GraphViewModels.cs | 6 +- .../DependencyInjection.cs | 4 +- .../Services/KnowledgeService.cs | 39 +++- .../Services/PromptRegistry.cs | 8 +- .../Components/Atoms/NexusIcon.razor | 3 + .../Molecules/AiAssistantBubble.razor | 3 - .../Molecules/IntelligenceToolbar.razor | 16 ++ .../Molecules/IntelligenceToolbar.razor.css | 5 + .../Components/Molecules/KnowledgeCheck.razor | 30 ++- .../Molecules/KnowledgeCheck.razor.css | 15 ++ .../Components/Organisms/KnowledgeGraph.razor | 16 ++ .../Components/Organisms/ReaderCanvas.razor | 26 +++ .../Layout/MainLayout.razor | 6 +- .../Layout/MainLayout.razor.css | 11 + .../Services/IKnowledgeGraphService.cs | 15 ++ .../Services/IQuizStateService.cs | 11 + .../Services/KnowledgeCoordinator.cs | 141 ++++++++++++ .../Services/KnowledgeGraphService.cs | 25 ++ .../Services/QuizStateService.cs | 29 +++ .../wwwroot/js/knowledgeGraph.js | 217 +++++++++++------- .../wwwroot/js/readerObserver.js | 22 ++ src/NexusReader.Web.Client/Program.cs | 3 + .../Services/WasmKnowledgeService.cs | 61 +++++ .../NexusReader.Web.csproj | 43 ++-- src/NexusReader.Web.New/Program.cs | 31 ++- src/NexusReader.Web.New/appsettings.json | 3 +- src/NexusReader.Web.New/nexus.db | Bin 0 -> 57344 bytes 30 files changed, 668 insertions(+), 153 deletions(-) create mode 100644 src/NexusReader.UI.Shared/Services/IKnowledgeGraphService.cs create mode 100644 src/NexusReader.UI.Shared/Services/KnowledgeCoordinator.cs create mode 100644 src/NexusReader.UI.Shared/Services/KnowledgeGraphService.cs create mode 100644 src/NexusReader.UI.Shared/wwwroot/js/readerObserver.js create mode 100644 src/NexusReader.Web.Client/Services/WasmKnowledgeService.cs create mode 100644 src/NexusReader.Web.New/nexus.db diff --git a/src/NexusReader.Application/Abstractions/Services/IKnowledgeService.cs b/src/NexusReader.Application/Abstractions/Services/IKnowledgeService.cs index 6d97bd8..f3d1100 100644 --- a/src/NexusReader.Application/Abstractions/Services/IKnowledgeService.cs +++ b/src/NexusReader.Application/Abstractions/Services/IKnowledgeService.cs @@ -6,4 +6,5 @@ namespace NexusReader.Application.Abstractions.Services; public interface IKnowledgeService { Task> GetKnowledgeAsync(string text, CancellationToken cancellationToken = default); + Task ClearCacheAsync(CancellationToken cancellationToken = default); } diff --git a/src/NexusReader.Application/DTOs/AI/KnowledgePacket.cs b/src/NexusReader.Application/DTOs/AI/KnowledgePacket.cs index 0551612..a6a1c23 100644 --- a/src/NexusReader.Application/DTOs/AI/KnowledgePacket.cs +++ b/src/NexusReader.Application/DTOs/AI/KnowledgePacket.cs @@ -13,7 +13,9 @@ public record QuizQuestion( [property: JsonPropertyName("correct_index")] int CorrectIndex ); -public record KnowledgePacket( - [property: JsonPropertyName("concepts")] List Concepts, - [property: JsonPropertyName("quizzes")] List Quizzes -); +public record KnowledgePacket +{ + [JsonPropertyName("concepts")] public List Concepts { get; init; } = new(); + [JsonPropertyName("quizzes")] public List Quizzes { get; init; } = new(); + [JsonPropertyName("graph")] public NexusReader.Application.Queries.Graph.GraphDataDto? Graph { get; init; } +} diff --git a/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQueryHandler.cs b/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQueryHandler.cs index b8e7e07..05f01ff 100644 --- a/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQueryHandler.cs +++ b/src/NexusReader.Application/Queries/Graph/GetKnowledgeGraphQueryHandler.cs @@ -7,24 +7,9 @@ internal sealed class GetKnowledgeGraphQueryHandler : IQueryHandler> Handle(GetKnowledgeGraphQuery request, CancellationToken cancellationToken) { - var nodes = new List - { - new("renesans-intro", "Renesans", "Concept"), - new("florencja", "Florencja", "Location"), - new("medyceusze", "Medyceusze", "Entity"), - new("da-vinci-ai", "Leonardo da Vinci", "Person"), - new("humanizm", "Humanizm", "Concept") - }; + var nodes = new List(); + var links = new List(); - var links = new List - { - new("renesans-intro", "florencja", 1), - new("florencja", "medyceusze", 2), - new("medyceusze", "da-vinci-ai", 3), - new("renesans-intro", "humanizm", 1), - new("da-vinci-ai", "humanizm", 2) - }; - - return Task.FromResult(Result.Ok(new GraphDataDto(nodes, links))); + return Task.FromResult(Result.Ok(new GraphDataDto { Nodes = nodes, Links = links })); } } diff --git a/src/NexusReader.Application/Queries/Graph/GraphViewModels.cs b/src/NexusReader.Application/Queries/Graph/GraphViewModels.cs index 00a0ce3..f9b4255 100644 --- a/src/NexusReader.Application/Queries/Graph/GraphViewModels.cs +++ b/src/NexusReader.Application/Queries/Graph/GraphViewModels.cs @@ -2,4 +2,8 @@ namespace NexusReader.Application.Queries.Graph; public record GraphNodeDto(string Id, string Label, string Group); public record GraphLinkDto(string Source, string Target, int Value); -public record GraphDataDto(List Nodes, List Links); +public record GraphDataDto +{ + public List Nodes { get; init; } = new(); + public List Links { get; init; } = new(); +} diff --git a/src/NexusReader.Infrastructure/DependencyInjection.cs b/src/NexusReader.Infrastructure/DependencyInjection.cs index a9ae87b..021ca0c 100644 --- a/src/NexusReader.Infrastructure/DependencyInjection.cs +++ b/src/NexusReader.Infrastructure/DependencyInjection.cs @@ -24,9 +24,11 @@ public static class DependencyInjection services.Configure(configuration.GetSection(AiSettings.SectionName)); var aiSettings = configuration.GetSection(AiSettings.SectionName).Get() ?? new AiSettings(); + Console.WriteLine($"[Infrastructure] AI Configured: Model={aiSettings.Model}, KeyPresent={!string.IsNullOrWhiteSpace(aiSettings.ApiKey) && aiSettings.ApiKey != "PLACEHOLDER"}"); + 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 + Console.WriteLine("[Infrastructure] WARNING: AI API Key is missing or placeholder!"); } services.AddResiliencePipeline("ai-retry", builder => diff --git a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs index 41f34b3..0643d39 100644 --- a/src/NexusReader.Infrastructure/Services/KnowledgeService.cs +++ b/src/NexusReader.Infrastructure/Services/KnowledgeService.cs @@ -41,12 +41,15 @@ public class KnowledgeService : IKnowledgeService return Result.Fail("Input text is empty."); } + Console.WriteLine($"[KnowledgeService] Starting extraction for text: {text.Substring(0, Math.Min(text.Length, 50))}..."); + // Normalize text to ensure consistent hashing and reduce token noise var normalizedText = ContentHasher.Normalize(text); // Phase 4: Request Pre-processing (Token Saving) if (normalizedText.Length > _settings.MaxInputLength) { + Console.WriteLine($"[KnowledgeService] Error: Input too long ({normalizedText.Length} > {_settings.MaxInputLength})"); return Result.Fail($"Input text is too long ({normalizedText.Length} characters after normalization). Max allowed is {_settings.MaxInputLength}."); } @@ -62,6 +65,7 @@ public class KnowledgeService : IKnowledgeService if (cached != null) { + Console.WriteLine($"[KnowledgeService] Cache hit for hash: {hash}"); try { var packet = JsonSerializer.Deserialize(cached.JsonData); @@ -70,18 +74,19 @@ public class KnowledgeService : IKnowledgeService return Result.Ok(packet); } } - catch (JsonException) + catch (JsonException ex) { - // If deserialization fails, we proceed to call the AI + Console.WriteLine($"[KnowledgeService] Cache deserialization error: {ex.Message}"); } } // 2. Call AI Client try { + Console.WriteLine($"[KnowledgeService] Calling Gemini AI with Model: {_settings.Model}..."); var options = new ChatOptions { - ResponseFormat = ChatResponseFormat.Json, + // ResponseFormat = ChatResponseFormat.Json, // Disabled due to GeminiMappingException in current library version Temperature = (float)_settings.Temperature, MaxOutputTokens = _settings.MaxOutputTokens }; @@ -96,19 +101,24 @@ public class KnowledgeService : IKnowledgeService var jsonResponse = response.Text; if (string.IsNullOrWhiteSpace(jsonResponse)) { + Console.WriteLine("[KnowledgeService] AI returned empty response."); return Result.Fail("AI returned an empty response."); } + Console.WriteLine($"[KnowledgeService] AI Response received ({jsonResponse.Length} chars)."); + // Cleanup potential markdown if Gemini still adds it despite options jsonResponse = jsonResponse.Replace("```json", "").Replace("```", "").Trim(); var knowledgePacket = JsonSerializer.Deserialize(jsonResponse); if (knowledgePacket == null) { + Console.WriteLine("[KnowledgeService] Failed to deserialize JSON response."); return Result.Fail("Failed to deserialize AI response."); } // 3. Save to Cache + Console.WriteLine("[KnowledgeService] Saving result to cache..."); var cacheEntry = new SemanticKnowledgeCache { ContentHash = hash, @@ -118,7 +128,6 @@ public class KnowledgeService : IKnowledgeService CreatedAt = DateTime.UtcNow }; - // Handle potential race condition if multiple requests for same text arrive if (cached == null) { _dbContext.SemanticKnowledgeCache.Add(cacheEntry); @@ -130,12 +139,34 @@ public class KnowledgeService : IKnowledgeService } await _dbContext.SaveChangesAsync(cancellationToken); + Console.WriteLine("[KnowledgeService] Extraction successful."); return Result.Ok(knowledgePacket); } catch (Exception ex) { + Console.WriteLine($"[KnowledgeService] CRITICAL ERROR: {ex.GetType().Name}: {ex.Message}"); + if (ex.InnerException != null) + Console.WriteLine($"[KnowledgeService] Inner Error: {ex.InnerException.Message}"); + return Result.Fail(new Error("Failed to extract knowledge from AI").CausedBy(ex)); } } + + public async Task ClearCacheAsync(CancellationToken cancellationToken = default) + { + try + { + Console.WriteLine("[KnowledgeService] Clearing SemanticKnowledgeCache..."); + _dbContext.SemanticKnowledgeCache.RemoveRange(_dbContext.SemanticKnowledgeCache); + await _dbContext.SaveChangesAsync(cancellationToken); + Console.WriteLine("[KnowledgeService] Cache cleared successfully."); + return Result.Ok(); + } + catch (Exception ex) + { + Console.WriteLine($"[KnowledgeService] Error clearing cache: {ex.Message}"); + return Result.Fail($"Failed to clear cache: {ex.Message}"); + } + } } diff --git a/src/NexusReader.Infrastructure/Services/PromptRegistry.cs b/src/NexusReader.Infrastructure/Services/PromptRegistry.cs index 628396e..08e1023 100644 --- a/src/NexusReader.Infrastructure/Services/PromptRegistry.cs +++ b/src/NexusReader.Infrastructure/Services/PromptRegistry.cs @@ -3,7 +3,11 @@ 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. " + + "You are an expert educator. Analyze the provided text to extract key concepts, generate relevant quizzes, and construct a knowledge graph. " + "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 } ] }."; + "Schema: { " + + "\"concepts\": [ { \"title\": \"string\", \"description\": \"string\" } ], " + + "\"quizzes\": [ { \"question\": \"string\", \"options\": [ \"string\" ], \"correct_index\": 0 } ], " + + "\"graph\": { \"nodes\": [ { \"id\": \"string\", \"label\": \"string\", \"group\": \"concept\" } ], \"links\": [ { \"source\": \"string\", \"target\": \"string\", \"value\": 1 } ] } " + + "}."; } diff --git a/src/NexusReader.UI.Shared/Components/Atoms/NexusIcon.razor b/src/NexusReader.UI.Shared/Components/Atoms/NexusIcon.razor index 1e48266..face0e9 100644 --- a/src/NexusReader.UI.Shared/Components/Atoms/NexusIcon.razor +++ b/src/NexusReader.UI.Shared/Components/Atoms/NexusIcon.razor @@ -25,6 +25,9 @@ case "target": break; + case "trash": + + break; default: diff --git a/src/NexusReader.UI.Shared/Components/Molecules/AiAssistantBubble.razor b/src/NexusReader.UI.Shared/Components/Molecules/AiAssistantBubble.razor index 6bee4ae..37ab954 100644 --- a/src/NexusReader.UI.Shared/Components/Molecules/AiAssistantBubble.razor +++ b/src/NexusReader.UI.Shared/Components/Molecules/AiAssistantBubble.razor @@ -31,13 +31,10 @@ [Parameter] public List Actions { get; set; } = new(); [Parameter] public EventCallback OnActionTriggered { get; set; } - private bool _isQuizMode = false; - private async Task HandleActionClick(string action) { if (action.Contains("quiz", StringComparison.OrdinalIgnoreCase)) { - _isQuizMode = true; QuizState.RequestQuiz(ContextBlockId); } diff --git a/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor b/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor index ebbe682..40da5ff 100644 --- a/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor +++ b/src/NexusReader.UI.Shared/Components/Molecules/IntelligenceToolbar.razor @@ -1,5 +1,7 @@ @using NexusReader.UI.Shared.Services +@using NexusReader.Application.Abstractions.Services @inject IFocusModeService FocusMode +@inject IKnowledgeService KnowledgeService