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

199 lines
6.6 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 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; }
}