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)
.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}");
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;
}
// ── 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());
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)
{
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
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();
}