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;
///
/// Calls OpenAI 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();
// ── initialisation ──────────────────────────────────────────────
public static void Initialize()
{
var configPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Resources", "openAiConfig.json");
if (!File.Exists(configPath))
{
// Fallback: look relative to the working directory (useful in dev)
configPath = Path.Combine("Resources", "openAiConfig.json");
}
if (!File.Exists(configPath))
{
Console.Error.WriteLine("[DiagnosticService] openAiConfig.json not found – AI diagnostics disabled.");
return;
}
var json = File.ReadAllText(configPath);
var config = JsonConvert.DeserializeObject(json);
if (config is null || string.IsNullOrWhiteSpace(config.ApiKey))
{
Console.Error.WriteLine("[DiagnosticService] ApiKey is empty – AI diagnostics disabled.");
return;
}
_apiKey = config.ApiKey;
Console.WriteLine("[DiagnosticService] initialised.");
}
public static bool IsEnabled => !string.IsNullOrEmpty(_apiKey);
// ── public entry-point ──────────────────────────────────────────
///
/// Returns a diagnosis for .
/// First checks the static AlarmKnowledgeBase for known Sinexcel/Growatt alarms.
/// Falls back to in-memory cache, then calls OpenAI only for unknown alarms.
///
public static async Task 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 OpenAI API (only for unknown alarms)
Console.WriteLine($"[DiagnosticService] Calling OpenAI for unknown alarm: {errorDescription}");
var prompt = BuildPrompt(errorDescription, productName, recentDescriptions);
var response = await CallOpenAiAsync(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 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"":[""...""]}}
";
}
// ── OpenAI HTTP call ────────────────────────────────────────────
private static readonly string OpenAiUrl = "https://api.openai.com/v1/chat/completions";
private static async Task CallOpenAiAsync(string userPrompt)
{
try
{
var requestBody = new
{
model = "gpt-4o-mini", // cost-efficient, fast; swap to "gpt-4" 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 OpenAiUrl
.SetHeader("Authorization", $"Bearer {_apiKey}")
.SetHeader("Content-Type", "application/json")
.PostJsonAsync(requestBody)
.ReceiveString();
// parse OpenAI envelope
var envelope = JsonConvert.DeserializeObject(responseText);
var content = (string?) envelope?.choices?[0]?.message?.content;
if (string.IsNullOrWhiteSpace(content))
{
Console.Error.WriteLine("[DiagnosticService] OpenAI returned empty content.");
return null;
}
// parse the JSON the model produced
var diagnostic = JsonConvert.DeserializeObject(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 OpenAiConfig
{
public string ApiKey { get; set; } = "";
}
public class DiagnosticResponse
{
public string Explanation { get; set; } = "";
public IReadOnlyList Causes { get; set; } = Array.Empty();
public IReadOnlyList NextSteps { get; set; } = Array.Empty();
}