Innovenergy_trunk/csharp/App/Backend/Services/DiagnosticService.cs

295 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>();
}