199 lines
6.6 KiB
C#
199 lines
6.6 KiB
C#
using Flurl.Http;
|
||
using InnovEnergy.App.Backend.Database;
|
||
using InnovEnergy.App.Backend.DataTypes;
|
||
using Newtonsoft.Json;
|
||
|
||
namespace InnovEnergy.App.Backend.Services;
|
||
|
||
/// <summary>
|
||
/// Generates AI-powered diagnoses for support tickets.
|
||
/// Runs async after ticket creation; stores result in TicketAiDiagnosis table.
|
||
/// </summary>
|
||
public static class TicketDiagnosticService
|
||
{
|
||
private static string _apiKey = "";
|
||
private static readonly string MistralUrl = "https://api.mistral.ai/v1/chat/completions";
|
||
|
||
public static void Initialize()
|
||
{
|
||
var apiKey = Environment.GetEnvironmentVariable("MISTRAL_API_KEY");
|
||
if (string.IsNullOrWhiteSpace(apiKey))
|
||
Console.Error.WriteLine("[TicketDiagnosticService] MISTRAL_API_KEY not set – ticket AI disabled.");
|
||
else
|
||
_apiKey = apiKey;
|
||
|
||
Console.WriteLine("[TicketDiagnosticService] initialised.");
|
||
}
|
||
|
||
public static bool IsEnabled => !string.IsNullOrEmpty(_apiKey);
|
||
|
||
/// <summary>
|
||
/// Called fire-and-forget after ticket creation.
|
||
/// Creates a TicketAiDiagnosis row (Pending → Analyzing → Completed | Failed).
|
||
/// </summary>
|
||
public static async Task DiagnoseTicketAsync(Int64 ticketId)
|
||
{
|
||
var ticket = Db.GetTicketById(ticketId);
|
||
if (ticket is null) return;
|
||
|
||
var installation = Db.GetInstallationById(ticket.InstallationId);
|
||
if (installation is null) return;
|
||
|
||
var diagnosis = new TicketAiDiagnosis
|
||
{
|
||
TicketId = ticketId,
|
||
Status = (Int32)DiagnosisStatus.Pending,
|
||
CreatedAt = DateTime.UtcNow
|
||
};
|
||
Db.Create(diagnosis);
|
||
|
||
if (!IsEnabled)
|
||
{
|
||
diagnosis.Status = (Int32)DiagnosisStatus.Failed;
|
||
Db.Update(diagnosis);
|
||
return;
|
||
}
|
||
|
||
diagnosis.Status = (Int32)DiagnosisStatus.Analyzing;
|
||
Db.Update(diagnosis);
|
||
|
||
try
|
||
{
|
||
var productName = ((ProductType)installation.Product).ToString();
|
||
|
||
var recentErrors = Db.Errors
|
||
.Where(e => e.InstallationId == ticket.InstallationId)
|
||
.OrderByDescending(e => e.Date)
|
||
.ToList()
|
||
.Select(e => e.Description)
|
||
.Distinct()
|
||
.Take(5)
|
||
.ToList();
|
||
|
||
var prompt = BuildPrompt(ticket, productName, recentErrors);
|
||
var result = await CallMistralAsync(prompt);
|
||
|
||
if (result is null)
|
||
{
|
||
diagnosis.Status = (Int32)DiagnosisStatus.Failed;
|
||
}
|
||
else
|
||
{
|
||
diagnosis.Status = (Int32)DiagnosisStatus.Completed;
|
||
diagnosis.RootCause = result.RootCause;
|
||
diagnosis.Confidence = result.Confidence;
|
||
diagnosis.RecommendedActions = result.RecommendedActionsJson;
|
||
diagnosis.CompletedAt = DateTime.UtcNow;
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Console.Error.WriteLine($"[TicketDiagnosticService] {ex.Message}");
|
||
diagnosis.Status = (Int32)DiagnosisStatus.Failed;
|
||
}
|
||
|
||
Db.Update(diagnosis);
|
||
|
||
Db.Create(new TicketTimelineEvent
|
||
{
|
||
TicketId = ticketId,
|
||
EventType = (Int32)TimelineEventType.AiDiagnosisAttached,
|
||
Description = diagnosis.Status == (Int32)DiagnosisStatus.Completed
|
||
? "AI diagnosis completed."
|
||
: "AI diagnosis failed.",
|
||
ActorType = (Int32)TimelineActorType.AiAgent,
|
||
CreatedAt = DateTime.UtcNow
|
||
});
|
||
}
|
||
|
||
private static string BuildPrompt(Ticket ticket, string productName, List<string> recentErrors)
|
||
{
|
||
var recentList = recentErrors.Count > 0
|
||
? string.Join(", ", recentErrors)
|
||
: "none";
|
||
|
||
return $@"You are a senior field technician for {productName} battery energy storage systems.
|
||
A support ticket has been submitted with the following details:
|
||
Subject: {ticket.Subject}
|
||
Description: {ticket.Description}
|
||
Category: {(TicketCategory)ticket.Category}
|
||
Priority: {(TicketPriority)ticket.Priority}
|
||
Recent system alarms: {recentList}
|
||
|
||
Analyze this ticket and respond in JSON only — no markdown, no explanation outside JSON:
|
||
{{
|
||
""rootCause"": ""One concise sentence describing the most likely root cause."",
|
||
""confidence"": 0.85,
|
||
""recommendedActions"": [""Action 1"", ""Action 2"", ""Action 3""]
|
||
}}
|
||
Confidence must be a number between 0.0 and 1.0.";
|
||
}
|
||
|
||
private static async Task<TicketDiagnosisResult?> CallMistralAsync(string prompt)
|
||
{
|
||
try
|
||
{
|
||
var body = new
|
||
{
|
||
model = "mistral-small-latest",
|
||
messages = new[] { new { role = "user", content = prompt } },
|
||
max_tokens = 300,
|
||
temperature = 0.2
|
||
};
|
||
|
||
var text = await MistralUrl
|
||
.WithHeader("Authorization", $"Bearer {_apiKey}")
|
||
.PostJsonAsync(body)
|
||
.ReceiveString();
|
||
|
||
var envelope = JsonConvert.DeserializeObject<dynamic>(text);
|
||
var content = (string?)envelope?.choices?[0]?.message?.content;
|
||
|
||
if (string.IsNullOrWhiteSpace(content)) return null;
|
||
|
||
var json = content.Trim();
|
||
if (json.StartsWith("```"))
|
||
{
|
||
var nl = json.IndexOf('\n');
|
||
if (nl >= 0) json = json[(nl + 1)..];
|
||
if (json.EndsWith("```")) json = json[..^3];
|
||
json = json.Trim();
|
||
}
|
||
|
||
var parsed = JsonConvert.DeserializeObject<TicketDiagnosisRaw>(json);
|
||
if (parsed is null) return null;
|
||
|
||
return new TicketDiagnosisResult
|
||
{
|
||
RootCause = parsed.RootCause,
|
||
Confidence = parsed.Confidence,
|
||
RecommendedActionsJson = JsonConvert.SerializeObject(parsed.RecommendedActions ?? Array.Empty<string>())
|
||
};
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Console.Error.WriteLine($"[TicketDiagnosticService] HTTP error: {ex.Message}");
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
|
||
internal class TicketDiagnosisRaw
|
||
{
|
||
[JsonProperty("rootCause")]
|
||
public String? RootCause { get; set; }
|
||
|
||
[JsonProperty("confidence")]
|
||
public Double? Confidence { get; set; }
|
||
|
||
[JsonProperty("recommendedActions")]
|
||
public String[]? RecommendedActions { get; set; }
|
||
}
|
||
|
||
internal class TicketDiagnosisResult
|
||
{
|
||
public String? RootCause { get; set; }
|
||
public Double? Confidence { get; set; }
|
||
public String? RecommendedActionsJson { get; set; }
|
||
}
|