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 (en, de, fr, it) if available
// en.json is generated by generate_alarm_translations.py after the review campaign
var resourcesDir = Path.Combine(AppContext.BaseDirectory, "Resources");
foreach (var lang in new[] { "en", "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)
{
// Check JSON translations first (en.json exists after review campaign)
if (Translations.TryGetValue(language, out var langDict) &&
langDict.TryGetValue(errorDescription, out var translated))
return translated;
// Fallback: English from compiled AlarmKnowledgeBase.cs (until en.json is deployed)
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,
};
}
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();
}