added ticket dashboard backend: data models, CRUD, AI diagnosis service, and 9 controller endpoints
Week 1 of ticket dashboard MVP — backend only: - 4 new SQLite tables: Ticket, TicketComment, TicketAiDiagnosis, TicketTimelineEvent - CRUD methods in Database/ partial classes with cascade delete - TicketDiagnosticService for async Mistral AI diagnosis (fire-and-forget) - 9 admin-only Controller endpoints for ticket CRUD, comments, and detail view - Ticket cleanup integrated into Delete(Installation) cascade
This commit is contained in:
parent
534b00aeb8
commit
9cee5398d4
|
|
@ -1746,6 +1746,172 @@ public class Controller : ControllerBase
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
// TICKET ENDPOINTS (admin-only)
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
[HttpGet(nameof(GetAllTickets))]
|
||||||
|
public ActionResult<IEnumerable<Ticket>> GetAllTickets(Token authToken)
|
||||||
|
{
|
||||||
|
var user = Db.GetSession(authToken)?.User;
|
||||||
|
if (user is null || user.UserType != 2) return Unauthorized();
|
||||||
|
|
||||||
|
return Db.GetAllTickets();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet(nameof(GetTicketsForInstallation))]
|
||||||
|
public ActionResult<IEnumerable<Ticket>> GetTicketsForInstallation(Int64 installationId, Token authToken)
|
||||||
|
{
|
||||||
|
var user = Db.GetSession(authToken)?.User;
|
||||||
|
if (user is null || user.UserType != 2) return Unauthorized();
|
||||||
|
|
||||||
|
var installation = Db.GetInstallationById(installationId);
|
||||||
|
if (installation is null) return NotFound();
|
||||||
|
|
||||||
|
return Db.GetTicketsForInstallation(installationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet(nameof(GetTicketById))]
|
||||||
|
public ActionResult<Ticket> GetTicketById(Int64 id, Token authToken)
|
||||||
|
{
|
||||||
|
var user = Db.GetSession(authToken)?.User;
|
||||||
|
if (user is null || user.UserType != 2) return Unauthorized();
|
||||||
|
|
||||||
|
var ticket = Db.GetTicketById(id);
|
||||||
|
return ticket is null ? NotFound() : ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost(nameof(CreateTicket))]
|
||||||
|
public ActionResult<Ticket> CreateTicket([FromBody] Ticket ticket, Token authToken)
|
||||||
|
{
|
||||||
|
var user = Db.GetSession(authToken)?.User;
|
||||||
|
if (user is null || user.UserType != 2) return Unauthorized();
|
||||||
|
|
||||||
|
ticket.CreatedByUserId = user.Id;
|
||||||
|
ticket.CreatedAt = DateTime.UtcNow;
|
||||||
|
ticket.UpdatedAt = DateTime.UtcNow;
|
||||||
|
ticket.Status = (Int32)TicketStatus.Open;
|
||||||
|
|
||||||
|
if (!Db.Create(ticket)) return StatusCode(500, "Failed to create ticket.");
|
||||||
|
|
||||||
|
Db.Create(new TicketTimelineEvent
|
||||||
|
{
|
||||||
|
TicketId = ticket.Id,
|
||||||
|
EventType = (Int32)TimelineEventType.Created,
|
||||||
|
Description = $"Ticket created by {user.Name}.",
|
||||||
|
ActorType = (Int32)TimelineActorType.Human,
|
||||||
|
ActorId = user.Id,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fire-and-forget AI diagnosis
|
||||||
|
TicketDiagnosticService.DiagnoseTicketAsync(ticket.Id).SupressAwaitWarning();
|
||||||
|
|
||||||
|
return ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut(nameof(UpdateTicket))]
|
||||||
|
public ActionResult<Ticket> UpdateTicket([FromBody] Ticket ticket, Token authToken)
|
||||||
|
{
|
||||||
|
var user = Db.GetSession(authToken)?.User;
|
||||||
|
if (user is null || user.UserType != 2) return Unauthorized();
|
||||||
|
|
||||||
|
var existing = Db.GetTicketById(ticket.Id);
|
||||||
|
if (existing is null) return NotFound();
|
||||||
|
|
||||||
|
ticket.CreatedAt = existing.CreatedAt;
|
||||||
|
ticket.CreatedByUserId = existing.CreatedByUserId;
|
||||||
|
ticket.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
if (ticket.Status != existing.Status)
|
||||||
|
{
|
||||||
|
if (ticket.Status == (Int32)TicketStatus.Resolved)
|
||||||
|
ticket.ResolvedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
Db.Create(new TicketTimelineEvent
|
||||||
|
{
|
||||||
|
TicketId = ticket.Id,
|
||||||
|
EventType = (Int32)TimelineEventType.StatusChanged,
|
||||||
|
Description = $"Status changed to {(TicketStatus)ticket.Status}.",
|
||||||
|
ActorType = (Int32)TimelineActorType.Human,
|
||||||
|
ActorId = user.Id,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Db.Update(ticket) ? ticket : StatusCode(500, "Update failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete(nameof(DeleteTicket))]
|
||||||
|
public ActionResult DeleteTicket(Int64 id, Token authToken)
|
||||||
|
{
|
||||||
|
var user = Db.GetSession(authToken)?.User;
|
||||||
|
if (user is null || user.UserType != 2) return Unauthorized();
|
||||||
|
|
||||||
|
var ticket = Db.GetTicketById(id);
|
||||||
|
if (ticket is null) return NotFound();
|
||||||
|
|
||||||
|
return Db.Delete(ticket) ? Ok() : StatusCode(500, "Delete failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet(nameof(GetTicketComments))]
|
||||||
|
public ActionResult<IEnumerable<TicketComment>> GetTicketComments(Int64 ticketId, Token authToken)
|
||||||
|
{
|
||||||
|
var user = Db.GetSession(authToken)?.User;
|
||||||
|
if (user is null || user.UserType != 2) return Unauthorized();
|
||||||
|
|
||||||
|
return Db.GetCommentsForTicket(ticketId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost(nameof(AddTicketComment))]
|
||||||
|
public ActionResult<TicketComment> AddTicketComment([FromBody] TicketComment comment, Token authToken)
|
||||||
|
{
|
||||||
|
var user = Db.GetSession(authToken)?.User;
|
||||||
|
if (user is null || user.UserType != 2) return Unauthorized();
|
||||||
|
|
||||||
|
var ticket = Db.GetTicketById(comment.TicketId);
|
||||||
|
if (ticket is null) return NotFound();
|
||||||
|
|
||||||
|
comment.AuthorType = (Int32)CommentAuthorType.Human;
|
||||||
|
comment.AuthorId = user.Id;
|
||||||
|
comment.CreatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
if (!Db.Create(comment)) return StatusCode(500, "Failed to add comment.");
|
||||||
|
|
||||||
|
Db.Create(new TicketTimelineEvent
|
||||||
|
{
|
||||||
|
TicketId = comment.TicketId,
|
||||||
|
EventType = (Int32)TimelineEventType.CommentAdded,
|
||||||
|
Description = $"Comment added by {user.Name}.",
|
||||||
|
ActorType = (Int32)TimelineActorType.Human,
|
||||||
|
ActorId = user.Id,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
|
||||||
|
ticket.UpdatedAt = DateTime.UtcNow;
|
||||||
|
Db.Update(ticket);
|
||||||
|
|
||||||
|
return comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet(nameof(GetTicketDetail))]
|
||||||
|
public ActionResult<Object> GetTicketDetail(Int64 id, Token authToken)
|
||||||
|
{
|
||||||
|
var user = Db.GetSession(authToken)?.User;
|
||||||
|
if (user is null || user.UserType != 2) return Unauthorized();
|
||||||
|
|
||||||
|
var ticket = Db.GetTicketById(id);
|
||||||
|
if (ticket is null) return NotFound();
|
||||||
|
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
ticket,
|
||||||
|
comments = Db.GetCommentsForTicket(id),
|
||||||
|
diagnosis = Db.GetDiagnosisForTicket(id),
|
||||||
|
timeline = Db.GetTimelineForTicket(id)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
using SQLite;
|
||||||
|
|
||||||
|
namespace InnovEnergy.App.Backend.DataTypes;
|
||||||
|
|
||||||
|
public enum TicketStatus { Open = 0, InProgress = 1, Escalated = 2, Resolved = 3, Closed = 4 }
|
||||||
|
public enum TicketPriority { Critical = 0, High = 1, Medium = 2, Low = 3 }
|
||||||
|
public enum TicketCategory { Hardware = 0, Software = 1, DataApi = 2, UserAccess = 3 }
|
||||||
|
public enum TicketSource { Manual = 0, AutoAlert = 1, Email = 2, Api = 3 }
|
||||||
|
|
||||||
|
public class Ticket
|
||||||
|
{
|
||||||
|
[PrimaryKey, AutoIncrement] public Int64 Id { get; set; }
|
||||||
|
|
||||||
|
public String Subject { get; set; } = "";
|
||||||
|
public String Description { get; set; } = "";
|
||||||
|
|
||||||
|
[Indexed] public Int32 Status { get; set; } = (Int32)TicketStatus.Open;
|
||||||
|
public Int32 Priority { get; set; } = (Int32)TicketPriority.Medium;
|
||||||
|
public Int32 Category { get; set; } = (Int32)TicketCategory.Hardware;
|
||||||
|
public Int32 Source { get; set; } = (Int32)TicketSource.Manual;
|
||||||
|
|
||||||
|
[Indexed] public Int64 InstallationId { get; set; }
|
||||||
|
public Int64? AssigneeId { get; set; }
|
||||||
|
[Indexed] public Int64 CreatedByUserId { get; set; }
|
||||||
|
|
||||||
|
public String Tags { get; set; } = "";
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime? ResolvedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
using SQLite;
|
||||||
|
|
||||||
|
namespace InnovEnergy.App.Backend.DataTypes;
|
||||||
|
|
||||||
|
public enum DiagnosisStatus { Pending = 0, Analyzing = 1, Completed = 2, Failed = 3 }
|
||||||
|
public enum DiagnosisFeedback { Accepted = 0, Rejected = 1, Overridden = 2 }
|
||||||
|
|
||||||
|
public class TicketAiDiagnosis
|
||||||
|
{
|
||||||
|
[PrimaryKey, AutoIncrement] public Int64 Id { get; set; }
|
||||||
|
|
||||||
|
[Indexed] public Int64 TicketId { get; set; }
|
||||||
|
public Int32 Status { get; set; } = (Int32)DiagnosisStatus.Pending;
|
||||||
|
public String? RootCause { get; set; }
|
||||||
|
public Double? Confidence { get; set; }
|
||||||
|
public String? RecommendedActions { get; set; } // JSON array string
|
||||||
|
public String? SimilarTicketIds { get; set; } // comma-separated
|
||||||
|
public Int32? Feedback { get; set; } // null = no feedback yet
|
||||||
|
public String? OverrideText { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime? CompletedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
using SQLite;
|
||||||
|
|
||||||
|
namespace InnovEnergy.App.Backend.DataTypes;
|
||||||
|
|
||||||
|
public enum CommentAuthorType { Human = 0, AiAgent = 1 }
|
||||||
|
|
||||||
|
public class TicketComment
|
||||||
|
{
|
||||||
|
[PrimaryKey, AutoIncrement] public Int64 Id { get; set; }
|
||||||
|
|
||||||
|
[Indexed] public Int64 TicketId { get; set; }
|
||||||
|
public Int32 AuthorType { get; set; } = (Int32)CommentAuthorType.Human;
|
||||||
|
public Int64? AuthorId { get; set; }
|
||||||
|
public String Body { get; set; } = "";
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
using SQLite;
|
||||||
|
|
||||||
|
namespace InnovEnergy.App.Backend.DataTypes;
|
||||||
|
|
||||||
|
public enum TimelineEventType
|
||||||
|
{
|
||||||
|
Created = 0, StatusChanged = 1, Assigned = 2,
|
||||||
|
CommentAdded = 3, AiDiagnosisAttached = 4, Escalated = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TimelineActorType { System = 0, AiAgent = 1, Human = 2 }
|
||||||
|
|
||||||
|
public class TicketTimelineEvent
|
||||||
|
{
|
||||||
|
[PrimaryKey, AutoIncrement] public Int64 Id { get; set; }
|
||||||
|
|
||||||
|
[Indexed] public Int64 TicketId { get; set; }
|
||||||
|
public Int32 EventType { get; set; }
|
||||||
|
public String Description { get; set; } = "";
|
||||||
|
public Int32 ActorType { get; set; } = (Int32)TimelineActorType.System;
|
||||||
|
public Int64? ActorId { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
@ -74,6 +74,12 @@ public static partial class Db
|
||||||
public static Boolean Create(DailyEnergyRecord record) => Insert(record);
|
public static Boolean Create(DailyEnergyRecord record) => Insert(record);
|
||||||
public static Boolean Create(HourlyEnergyRecord record) => Insert(record);
|
public static Boolean Create(HourlyEnergyRecord record) => Insert(record);
|
||||||
public static Boolean Create(AiInsightCache cache) => Insert(cache);
|
public static Boolean Create(AiInsightCache cache) => Insert(cache);
|
||||||
|
|
||||||
|
// Ticket system
|
||||||
|
public static Boolean Create(Ticket ticket) => Insert(ticket);
|
||||||
|
public static Boolean Create(TicketComment comment) => Insert(comment);
|
||||||
|
public static Boolean Create(TicketAiDiagnosis diagnosis) => Insert(diagnosis);
|
||||||
|
public static Boolean Create(TicketTimelineEvent ev) => Insert(ev);
|
||||||
|
|
||||||
public static void HandleAction(UserAction newAction)
|
public static void HandleAction(UserAction newAction)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,12 @@ public static partial class Db
|
||||||
public static TableQuery<HourlyEnergyRecord> HourlyRecords => Connection.Table<HourlyEnergyRecord>();
|
public static TableQuery<HourlyEnergyRecord> HourlyRecords => Connection.Table<HourlyEnergyRecord>();
|
||||||
public static TableQuery<AiInsightCache> AiInsightCaches => Connection.Table<AiInsightCache>();
|
public static TableQuery<AiInsightCache> AiInsightCaches => Connection.Table<AiInsightCache>();
|
||||||
|
|
||||||
|
// Ticket system tables
|
||||||
|
public static TableQuery<Ticket> Tickets => Connection.Table<Ticket>();
|
||||||
|
public static TableQuery<TicketComment> TicketComments => Connection.Table<TicketComment>();
|
||||||
|
public static TableQuery<TicketAiDiagnosis> TicketAiDiagnoses => Connection.Table<TicketAiDiagnosis>();
|
||||||
|
public static TableQuery<TicketTimelineEvent> TicketTimelineEvents => Connection.Table<TicketTimelineEvent>();
|
||||||
|
|
||||||
|
|
||||||
public static void Init()
|
public static void Init()
|
||||||
{
|
{
|
||||||
|
|
@ -63,6 +69,12 @@ public static partial class Db
|
||||||
Connection.CreateTable<DailyEnergyRecord>();
|
Connection.CreateTable<DailyEnergyRecord>();
|
||||||
Connection.CreateTable<HourlyEnergyRecord>();
|
Connection.CreateTable<HourlyEnergyRecord>();
|
||||||
Connection.CreateTable<AiInsightCache>();
|
Connection.CreateTable<AiInsightCache>();
|
||||||
|
|
||||||
|
// Ticket system tables
|
||||||
|
Connection.CreateTable<Ticket>();
|
||||||
|
Connection.CreateTable<TicketComment>();
|
||||||
|
Connection.CreateTable<TicketAiDiagnosis>();
|
||||||
|
Connection.CreateTable<TicketTimelineEvent>();
|
||||||
});
|
});
|
||||||
|
|
||||||
// One-time migration: normalize legacy long-form language values to ISO codes
|
// One-time migration: normalize legacy long-form language values to ISO codes
|
||||||
|
|
@ -112,6 +124,12 @@ public static partial class Db
|
||||||
fileConnection.CreateTable<HourlyEnergyRecord>();
|
fileConnection.CreateTable<HourlyEnergyRecord>();
|
||||||
fileConnection.CreateTable<AiInsightCache>();
|
fileConnection.CreateTable<AiInsightCache>();
|
||||||
|
|
||||||
|
// Ticket system tables
|
||||||
|
fileConnection.CreateTable<Ticket>();
|
||||||
|
fileConnection.CreateTable<TicketComment>();
|
||||||
|
fileConnection.CreateTable<TicketAiDiagnosis>();
|
||||||
|
fileConnection.CreateTable<TicketTimelineEvent>();
|
||||||
|
|
||||||
return fileConnection;
|
return fileConnection;
|
||||||
//return CopyDbToMemory(fileConnection);
|
//return CopyDbToMemory(fileConnection);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,17 @@ public static partial class Db
|
||||||
Warnings .Delete(w => w.InstallationId == installation.Id);
|
Warnings .Delete(w => w.InstallationId == installation.Id);
|
||||||
UserActions .Delete(a => a.InstallationId == installation.Id);
|
UserActions .Delete(a => a.InstallationId == installation.Id);
|
||||||
|
|
||||||
|
// Clean up tickets and their children for this installation
|
||||||
|
var ticketIds = Tickets.Where(t => t.InstallationId == installation.Id)
|
||||||
|
.Select(t => t.Id).ToList();
|
||||||
|
foreach (var tid in ticketIds)
|
||||||
|
{
|
||||||
|
TicketComments .Delete(c => c.TicketId == tid);
|
||||||
|
TicketAiDiagnoses .Delete(d => d.TicketId == tid);
|
||||||
|
TicketTimelineEvents.Delete(e => e.TicketId == tid);
|
||||||
|
}
|
||||||
|
Tickets.Delete(t => t.InstallationId == installation.Id);
|
||||||
|
|
||||||
return Installations.Delete(i => i.Id == installation.Id) > 0;
|
return Installations.Delete(i => i.Id == installation.Id) > 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -199,6 +210,21 @@ public static partial class Db
|
||||||
if (count > 0) Backup();
|
if (count > 0) Backup();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static Boolean Delete(Ticket ticket)
|
||||||
|
{
|
||||||
|
var deleteSuccess = RunTransaction(DeleteTicketAndChildren);
|
||||||
|
if (deleteSuccess) Backup();
|
||||||
|
return deleteSuccess;
|
||||||
|
|
||||||
|
Boolean DeleteTicketAndChildren()
|
||||||
|
{
|
||||||
|
TicketComments .Delete(c => c.TicketId == ticket.Id);
|
||||||
|
TicketAiDiagnoses .Delete(d => d.TicketId == ticket.Id);
|
||||||
|
TicketTimelineEvents.Delete(e => e.TicketId == ticket.Id);
|
||||||
|
return Tickets.Delete(t => t.Id == ticket.Id) > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Deletes all report records older than 1 year. Called annually on Jan 2
|
/// Deletes all report records older than 1 year. Called annually on Jan 2
|
||||||
/// after yearly reports are created. Uses fetch-then-delete for string-compared
|
/// after yearly reports are created. Uses fetch-then-delete for string-compared
|
||||||
|
|
|
||||||
|
|
@ -156,4 +156,33 @@ public static partial class Db
|
||||||
&& c.ReportId == reportId
|
&& c.ReportId == reportId
|
||||||
&& c.Language == language)
|
&& c.Language == language)
|
||||||
?.InsightText;
|
?.InsightText;
|
||||||
|
|
||||||
|
// ── Ticket Queries ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public static Ticket? GetTicketById(Int64 id)
|
||||||
|
=> Tickets.FirstOrDefault(t => t.Id == id);
|
||||||
|
|
||||||
|
public static List<Ticket> GetAllTickets()
|
||||||
|
=> Tickets.OrderByDescending(t => t.UpdatedAt).ToList();
|
||||||
|
|
||||||
|
public static List<Ticket> GetTicketsForInstallation(Int64 installationId)
|
||||||
|
=> Tickets
|
||||||
|
.Where(t => t.InstallationId == installationId)
|
||||||
|
.OrderByDescending(t => t.CreatedAt)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
public static List<TicketComment> GetCommentsForTicket(Int64 ticketId)
|
||||||
|
=> TicketComments
|
||||||
|
.Where(c => c.TicketId == ticketId)
|
||||||
|
.OrderBy(c => c.CreatedAt)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
public static TicketAiDiagnosis? GetDiagnosisForTicket(Int64 ticketId)
|
||||||
|
=> TicketAiDiagnoses.FirstOrDefault(d => d.TicketId == ticketId);
|
||||||
|
|
||||||
|
public static List<TicketTimelineEvent> GetTimelineForTicket(Int64 ticketId)
|
||||||
|
=> TicketTimelineEvents
|
||||||
|
.Where(e => e.TicketId == ticketId)
|
||||||
|
.OrderBy(e => e.CreatedAt)
|
||||||
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
@ -49,11 +49,14 @@ public static partial class Db
|
||||||
public static void UpdateAction(UserAction updatedAction)
|
public static void UpdateAction(UserAction updatedAction)
|
||||||
{
|
{
|
||||||
var existingAction = UserActions.FirstOrDefault(action => action.Id == updatedAction.Id);
|
var existingAction = UserActions.FirstOrDefault(action => action.Id == updatedAction.Id);
|
||||||
|
|
||||||
if (existingAction != null)
|
if (existingAction != null)
|
||||||
{
|
{
|
||||||
Update(updatedAction);
|
Update(updatedAction);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ticket system
|
||||||
|
public static Boolean Update(Ticket ticket) => Update(obj: ticket);
|
||||||
|
public static Boolean Update(TicketAiDiagnosis diagnosis) => Update(obj: diagnosis);
|
||||||
}
|
}
|
||||||
|
|
@ -27,6 +27,7 @@ public static class Program
|
||||||
Db.Init();
|
Db.Init();
|
||||||
LoadEnvFile();
|
LoadEnvFile();
|
||||||
DiagnosticService.Initialize();
|
DiagnosticService.Initialize();
|
||||||
|
TicketDiagnosticService.Initialize();
|
||||||
AlarmReviewService.StartDailyScheduler();
|
AlarmReviewService.StartDailyScheduler();
|
||||||
// DailyIngestionService.StartScheduler(); // Phase 2: enable when S3 auto-push is ready
|
// DailyIngestionService.StartScheduler(); // Phase 2: enable when S3 auto-push is ready
|
||||||
// ReportAggregationService.StartScheduler(); // Phase 2: enable when scheduler should auto-run monthly/yearly
|
// ReportAggregationService.StartScheduler(); // Phase 2: enable when scheduler should auto-run monthly/yearly
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,198 @@
|
||||||
|
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; }
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue