169 lines
6.9 KiB
C#
169 lines
6.9 KiB
C#
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;
|
||
|
||
/// <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();
|
||
|
||
// ── 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 ──────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Returns a diagnosis for <paramref name="errorDescription"/>.
|
||
/// First checks the static AlarmKnowledgeBase for known Sinexcel/Growatt alarms.
|
||
/// Falls back to in-memory cache, then calls Mistral AI only for unknown alarms.
|
||
/// </summary>
|
||
public static async Task<DiagnosticResponse?> 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<string> 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<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
|
||
.SetHeader("Authorization", $"Bearer {_apiKey}")
|
||
.SetHeader("Content-Type", "application/json")
|
||
.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;
|
||
}
|
||
|
||
// parse the JSON the model produced
|
||
var diagnostic = JsonConvert.DeserializeObject<DiagnosticResponse>(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<string> Causes { get; set; } = Array.Empty<string>();
|
||
public IReadOnlyList<string> NextSteps { get; set; } = Array.Empty<string>();
|
||
}
|