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

170 lines
6.9 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;
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)
.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;
}
// ── 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
.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;
}
// parse the JSON the model produced
var diagnostic = JsonConvert.DeserializeObject<DiagnosticResponse>(content);
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<string> Causes { get; set; } = Array.Empty<string>();
public IReadOnlyList<string> NextSteps { get; set; } = Array.Empty<string>();
}