using System.Collections.Concurrent; using Flurl.Http; using InnovEnergy.App.Backend.Database; using InnovEnergy.App.Backend.DataTypes; using Newtonsoft.Json; namespace InnovEnergy.App.Backend.Services; /// /// Calls Mistral AI to generate plain-English diagnostics for errors/warnings. /// Caches responses in-memory keyed by error description so the same /// error code is only sent to the API once. /// public static class DiagnosticService { private static string _apiKey = ""; /// In-memory cache: errorDescription → parsed response. private static readonly ConcurrentDictionary Cache = new(); // ── initialisation ────────────────────────────────────────────── public static void Initialize() { var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY"); if (string.IsNullOrWhiteSpace(apiKey)) { Console.Error.WriteLine("[DiagnosticService] MISTRAL_API_KEY not set – AI diagnostics disabled."); return; } _apiKey = apiKey; Console.WriteLine("[DiagnosticService] initialised."); } public static bool IsEnabled => !string.IsNullOrEmpty(_apiKey); // ── public entry-point ────────────────────────────────────────── private static string LanguageName(string code) => code switch { "de" => "German", "fr" => "French", "it" => "Italian", _ => "English" }; /// /// Returns a diagnosis for in the given language. /// For English: checks the static AlarmKnowledgeBase first, then in-memory cache, then Mistral AI. /// For other languages: skips the knowledge base (English-only) and goes directly to Mistral AI. /// Cache is keyed by (errorDescription, language) so each language is cached separately. /// public static async Task DiagnoseAsync(Int64 installationId, string errorDescription, string language = "en") { var cacheKey = $"{errorDescription}|{language}"; // 1. For English only: check the static knowledge base first (no API call needed) if (language == "en") { var knownDiagnosis = AlarmKnowledgeBase.TryGetDiagnosis(errorDescription); if (knownDiagnosis is not null) { Console.WriteLine($"[DiagnosticService] Found diagnosis in knowledge base for: {errorDescription}"); return knownDiagnosis; } } // 2. If AI is not enabled, we can't proceed further if (!IsEnabled) return null; // 3. Check in-memory cache for previously fetched AI diagnoses if (Cache.TryGetValue(cacheKey, out var cached)) return cached; // 4. Gather context from the DB for AI prompt var installation = Db.GetInstallationById(installationId); if (installation is null) return null; var productName = ((ProductType)installation.Product).ToString(); var recentDescriptions = Db.Errors .Where(e => e.InstallationId == installationId) .OrderByDescending(e => e.Date) .ThenByDescending(e => e.Time) .ToList() // materialize before LINQ-to-objects ops .Select(e => e.Description) .Distinct() // deduplicate — same error repeated adds no signal .Take(5) .ToList(); // 5. Build prompt and call Mistral API (only for unknown alarms) Console.WriteLine($"[DiagnosticService] Calling Mistral for unknown alarm: {errorDescription} ({language})"); var prompt = BuildPrompt(errorDescription, productName, recentDescriptions, language); var response = await CallMistralAsync(prompt); if (response is null) return null; // 6. Store in cache for future requests Cache.TryAdd(cacheKey, response); return response; } // ── test helper (no DB dependency) ───────────────────────────── /// /// Calls Mistral directly with a generic prompt. For testing only - no DB lookup. /// public static async Task TestCallMistralAsync(string errorDescription) { if (!IsEnabled) return null; // Check cache first if (Cache.TryGetValue(errorDescription, out var cached)) return cached; var prompt = BuildPrompt(errorDescription, "SodioHome", new List(), "en"); var response = await CallMistralAsync(prompt); if (response is not null) Cache.TryAdd(errorDescription, response); return response; } // ── prompt ────────────────────────────────────────────────────── private static string BuildPrompt(string errorDescription, string productName, List recentErrors, string language = "en") { var recentList = recentErrors.Count > 0 ? string.Join(", ", recentErrors) : "none"; return $@"You are a technician for Innovenergy {productName} battery energy storage systems. These are lithium-ion BESS units with a BMS, PV inverter, and grid inverter. Error: {errorDescription} Other recent errors: {recentList} Explain for a non-technical homeowner. Keep it very short and simple: - explanation: 1 short sentence, no jargon - causes: 2-3 bullet points, plain language - nextSteps: 2-3 simple action items a homeowner can understand IMPORTANT: Write all text values in {LanguageName(language)}. Reply with ONLY valid JSON, no markdown: {{""explanation"":""1 short sentence"",""causes"":[""...""],""nextSteps"":[""...""]}} "; } // ── Mistral HTTP call ──────────────────────────────────────────── private static readonly string MistralUrl = "https://api.mistral.ai/v1/chat/completions"; private static async Task CallMistralAsync(string userPrompt) { try { var requestBody = new { model = "mistral-small-latest", // cost-efficient, fast; swap to "mistral-large-latest" if quality needs tuning messages = new[] { new { role = "user", content = userPrompt } }, max_tokens = 400, temperature = 0.2 // low temperature for factual consistency }; var responseText = await MistralUrl .WithHeader("Authorization", $"Bearer {_apiKey}") .PostJsonAsync(requestBody) .ReceiveString(); // parse Mistral envelope (same structure as OpenAI) var envelope = JsonConvert.DeserializeObject(responseText); var content = (string?) envelope?.choices?[0]?.message?.content; if (string.IsNullOrWhiteSpace(content)) { Console.Error.WriteLine("[DiagnosticService] Mistral returned empty content."); return null; } // strip markdown code fences if Mistral wraps the JSON in ```json ... ``` var json = content.Trim(); if (json.StartsWith("```")) { var firstNewline = json.IndexOf('\n'); if (firstNewline >= 0) json = json[(firstNewline + 1)..]; if (json.EndsWith("```")) json = json[..^3]; json = json.Trim(); } // parse the JSON the model produced var diagnostic = JsonConvert.DeserializeObject(json); return diagnostic; } catch (FlurlHttpException httpEx) { Console.Error.WriteLine($"[DiagnosticService] HTTP error {httpEx.StatusCode}: {httpEx.Message}"); return null; } catch (Exception ex) { Console.Error.WriteLine($"[DiagnosticService] {ex.Message}"); return null; } } } // ── config / response models ──────────────────────────────────────────────── public class DiagnosticResponse { public string Explanation { get; set; } = ""; public IReadOnlyList Causes { get; set; } = Array.Empty(); public IReadOnlyList NextSteps { get; set; } = Array.Empty(); }