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 ────────────────────────────────────────── /// /// Returns a diagnosis for . /// First checks the static AlarmKnowledgeBase for known Sinexcel/Growatt alarms. /// Falls back to in-memory cache, then calls Mistral AI only for unknown alarms. /// public static async Task DiagnoseAsync(Int64 installationId, string errorDescription) { // 1. Check the static knowledge base first (no API call needed) 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(errorDescription, 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 + " " + e.Time) // Date/Time stored as strings in DB .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}"); var prompt = BuildPrompt(errorDescription, productName, recentDescriptions); var response = await CallMistralAsync(prompt); if (response is null) return null; // 6. Store in cache for future requests Cache.TryAdd(errorDescription, response); return response; } // ── prompt ────────────────────────────────────────────────────── private static string BuildPrompt(string errorDescription, string productName, List recentErrors) { 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 in plain English for a homeowner. List likely causes and next steps. Reply with ONLY valid JSON, no markdown: {{""explanation"":""2-3 sentences"",""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 .SetHeader("Authorization", $"Bearer {_apiKey}") .SetHeader("Content-Type", "application/json") .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; } // parse the JSON the model produced var diagnostic = JsonConvert.DeserializeObject(content); return diagnostic; } catch (FlurlHttpException httpEx) { Console.Error.WriteLine($"[DiagnosticService] HTTP error {httpEx.Response?.StatusCode}: {await httpEx.Response?.GetStringAsync()}"); 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(); }