using System.Collections.Concurrent; using Flurl.Http; using InnovEnergy.App.Backend.Database; using InnovEnergy.App.Backend.DataTypes; using Newtonsoft.Json; using System.Text.RegularExpressions; 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(); /// Pre-generated translations keyed by language code → alarm key → response. private static readonly Dictionary> Translations = 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."); else _apiKey = apiKey; // Load pre-generated translation files (de, fr, it) if available var resourcesDir = Path.Combine(AppContext.BaseDirectory, "Resources"); foreach (var lang in new[] { "de", "fr", "it" }) { var file = Path.Combine(resourcesDir, $"AlarmTranslations.{lang}.json"); if (!File.Exists(file)) continue; try { var json = File.ReadAllText(file); var raw = JsonConvert.DeserializeObject>(json); if (raw is not null) { Translations[lang] = raw; Console.WriteLine($"[DiagnosticService] Loaded {raw.Count} {lang} translations."); } } catch (Exception ex) { Console.Error.WriteLine($"[DiagnosticService] Failed to load AlarmTranslations.{lang}.json: {ex.Message}"); } } 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" }; /// Converts "AbnormalGridVoltage" → "Abnormal Grid Voltage". private static string SplitCamelCase(string name) => Regex.Replace(name, @"(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", " ").Trim(); /// /// 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: 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] Knowledge base hit (en): {errorDescription}"); // Return a new instance with Name set — avoids mutating the shared static dictionary return new DiagnosticResponse { Name = SplitCamelCase(errorDescription), Explanation = knownDiagnosis.Explanation, Causes = knownDiagnosis.Causes, NextSteps = knownDiagnosis.NextSteps, }; } } // 2. For non-English: check pre-generated translation files (no API call needed) if (language != "en" && Translations.TryGetValue(language, out var langDict)) { if (langDict.TryGetValue(errorDescription, out var translatedDiagnosis)) { Console.WriteLine($"[DiagnosticService] Pre-generated translation hit ({language}): {errorDescription}"); return translatedDiagnosis; } } // 3. If AI is not enabled, we can't proceed further if (!IsEnabled) return null; // 4. 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) ───────────────────────────── /// /// Returns a diagnosis from the static knowledge base (English) or pre-generated /// translations (other languages). Returns null if not found in either. /// public static DiagnosticResponse? TryGetTranslation(string errorDescription, string language) { if (language == "en") { var kb = AlarmKnowledgeBase.TryGetDiagnosis(errorDescription); if (kb is null) return null; return new DiagnosticResponse { Name = SplitCamelCase(errorDescription), Explanation = kb.Explanation, Causes = kb.Causes, NextSteps = kb.NextSteps, }; } if (Translations.TryGetValue(language, out var langDict) && langDict.TryGetValue(errorDescription, out var translated)) return translated; return null; } /// /// Calls Mistral directly with a generic prompt. For testing only - no DB lookup. /// public static async Task TestCallMistralAsync(string errorDescription, string language = "en") { if (!IsEnabled) return null; var cacheKey = $"{errorDescription}|{language}"; // Check cache first if (Cache.TryGetValue(cacheKey, out var cached)) return cached; var prompt = BuildPrompt(errorDescription, "SodioHome", new List(), language); var response = await CallMistralAsync(prompt); if (response is not null) Cache.TryAdd(cacheKey, 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 {productName} battery energy storage systems. These are sodium-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: - name: 2-5 word display title for this alarm - 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: {{""name"":""short title"",""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 Name { get; set; } = ""; public string Explanation { get; set; } = ""; public IReadOnlyList Causes { get; set; } = Array.Empty(); public IReadOnlyList NextSteps { get; set; } = Array.Empty(); }