295 lines
12 KiB
C#
295 lines
12 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
public static class DiagnosticService
|
||
{
|
||
private static string _apiKey = "";
|
||
|
||
/// <summary>In-memory cache: errorDescription → parsed response.</summary>
|
||
private static readonly ConcurrentDictionary<string, DiagnosticResponse> Cache = new();
|
||
|
||
/// <summary>Pre-generated translations keyed by language code → alarm key → response.</summary>
|
||
private static readonly Dictionary<string, IReadOnlyDictionary<string, DiagnosticResponse>> 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<Dictionary<string, DiagnosticResponse>>(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"
|
||
};
|
||
|
||
/// <summary>Converts "AbnormalGridVoltage" → "Abnormal Grid Voltage".</summary>
|
||
private static string SplitCamelCase(string name) =>
|
||
Regex.Replace(name, @"(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", " ").Trim();
|
||
|
||
/// <summary>
|
||
/// Returns a diagnosis for <paramref name="errorDescription"/> 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.
|
||
/// </summary>
|
||
public static async Task<DiagnosticResponse?> 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) ─────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Returns a diagnosis from the static knowledge base (English) or pre-generated
|
||
/// translations (other languages). Returns null if not found in either.
|
||
/// </summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Calls Mistral directly with a generic prompt. For testing only - no DB lookup.
|
||
/// </summary>
|
||
public static async Task<DiagnosticResponse?> 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<string>(), 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<string> 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:
|
||
- 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<DiagnosticResponse?> 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<dynamic>(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<DiagnosticResponse>(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<string> Causes { get; set; } = Array.Empty<string>();
|
||
public IReadOnlyList<string> NextSteps { get; set; } = Array.Empty<string>();
|
||
}
|