From c076d55407e325c4c12fdb5a15d3f11de01d8eb4 Mon Sep 17 00:00:00 2001 From: Yinyin Liu Date: Tue, 17 Feb 2026 12:16:35 +0100 Subject: [PATCH] AI diagnosis UX improvements: status-aware, time-filtered, simpler explanations - Only show AI diagnosis when installation status is red/orange (not green/offline) - Filter alarms to last 24 hours to avoid showing outdated issues - Show alarm name first with "Last seen" timestamp instead of "AI Diagnosis" label - Update Mistral prompt for shorter, non-technical bullet-point explanations - Fix Mistral JSON parsing when response wrapped in markdown code fences - Add TestDiagnoseError endpoint for testing full AI flow without auth Co-Authored-By: Claude Opus 4.6 --- csharp/App/Backend/Controller.cs | 41 +++++++++++++++++++ .../App/Backend/Services/DiagnosticService.cs | 41 +++++++++++++++++-- .../dashboards/Installations/Installation.tsx | 1 + .../src/content/dashboards/Log/Log.tsx | 31 ++++++++++---- .../SalidomoInstallations/Installation.tsx | 1 + .../SodiohomeInstallations/Installation.tsx | 1 + 6 files changed, 105 insertions(+), 11 deletions(-) diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs index a014cd253..08cba57c2 100644 --- a/csharp/App/Backend/Controller.cs +++ b/csharp/App/Backend/Controller.cs @@ -821,6 +821,47 @@ public class Controller : ControllerBase }); } + /// + /// Test endpoint for the full AI diagnostic flow (knowledge base + Mistral API). + /// No auth required. Remove before production. + /// Usage: GET /api/TestDiagnoseError?errorDescription=SomeAlarm + /// + [HttpGet(nameof(TestDiagnoseError))] + public async Task TestDiagnoseError(string errorDescription = "AbnormalGridVoltage") + { + // 1. Try knowledge base first + var kbResult = AlarmKnowledgeBase.TryGetDiagnosis(errorDescription); + if (kbResult is not null) + { + return Ok(new + { + Source = "KnowledgeBase", + Alarm = errorDescription, + MistralEnabled = DiagnosticService.IsEnabled, + kbResult.Explanation, + kbResult.Causes, + kbResult.NextSteps + }); + } + + // 2. If not in KB, try Mistral directly with a test prompt + if (!DiagnosticService.IsEnabled) + return Ok(new { Source = "None", Alarm = errorDescription, Message = "Not in knowledge base and Mistral API key not configured." }); + + var aiResult = await DiagnosticService.TestCallMistralAsync(errorDescription); + if (aiResult is null) + return Ok(new { Source = "MistralFailed", Alarm = errorDescription, Message = "Mistral API call failed or returned empty." }); + + return Ok(new + { + Source = "MistralAI", + Alarm = errorDescription, + aiResult.Explanation, + aiResult.Causes, + aiResult.NextSteps + }); + } + [HttpPut(nameof(UpdateFolder))] public ActionResult UpdateFolder([FromBody] Folder folder, Token authToken) { diff --git a/csharp/App/Backend/Services/DiagnosticService.cs b/csharp/App/Backend/Services/DiagnosticService.cs index 005734475..4757c97d7 100644 --- a/csharp/App/Backend/Services/DiagnosticService.cs +++ b/csharp/App/Backend/Services/DiagnosticService.cs @@ -88,6 +88,28 @@ public static class DiagnosticService return response; } + // ── test helper (no DB dependency) ───────────────────────────── + + /// + /// Calls Mistral directly with a generic prompt. For testing only - no DB lookup. + /// + public static async Task TestCallMistralAsync(string errorDescription) + { + if (!IsEnabled) return null; + + // Check cache first + if (Cache.TryGetValue(errorDescription, out var cached)) + return cached; + + var prompt = BuildPrompt(errorDescription, "SodioHome", new List()); + var response = await CallMistralAsync(prompt); + + if (response is not null) + Cache.TryAdd(errorDescription, response); + + return response; + } + // ── prompt ────────────────────────────────────────────────────── private static string BuildPrompt(string errorDescription, string productName, List recentErrors) @@ -102,9 +124,12 @@ 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. +Explain for a non-technical homeowner. Keep it very short and simple: +- explanation: 1 short sentence, no jargon +- causes: 2-3 bullet points, plain language +- nextSteps: 2-3 simple action items a homeowner can understand Reply with ONLY valid JSON, no markdown: -{{""explanation"":""2-3 sentences"",""causes"":[""...""],""nextSteps"":[""...""]}} +{{""explanation"":""1 short sentence"",""causes"":[""...""],""nextSteps"":[""...""]}} "; } @@ -142,8 +167,18 @@ Reply with ONLY valid JSON, no markdown: return null; } + // strip markdown code fences if Mistral wraps the JSON in ```json ... ``` + var json = content.Trim(); + if (json.StartsWith("```")) + { + var firstNewline = json.IndexOf('\n'); + if (firstNewline >= 0) json = json[(firstNewline + 1)..]; + if (json.EndsWith("```")) json = json[..^3]; + json = json.Trim(); + } + // parse the JSON the model produced - var diagnostic = JsonConvert.DeserializeObject(content); + var diagnostic = JsonConvert.DeserializeObject(json); return diagnostic; } catch (FlurlHttpException httpEx) diff --git a/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx b/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx index 205413cdc..6092a0670 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Installations/Installation.tsx @@ -461,6 +461,7 @@ function Installation(props: singleInstallationProps) { } /> diff --git a/typescript/frontend-marios2/src/content/dashboards/Log/Log.tsx b/typescript/frontend-marios2/src/content/dashboards/Log/Log.tsx index fa2b9b539..3fc396d2f 100644 --- a/typescript/frontend-marios2/src/content/dashboards/Log/Log.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/Log/Log.tsx @@ -27,6 +27,7 @@ import Checkbox from '@mui/material/Checkbox'; interface LogProps { errorLoadingS3Data: boolean; id: number; + status?: number; } function Log(props: LogProps) { @@ -47,7 +48,7 @@ function Log(props: LogProps) { const tokencontext = useContext(TokenContext); const { removeToken } = tokencontext; - const [diagnoses, setDiagnoses] = useState<{ description: string; response: DiagnosticResponse }[]>([]); + const [diagnoses, setDiagnoses] = useState<{ description: string; lastSeen: string; response: DiagnosticResponse }[]>([]); const [diagnosisLoading, setDiagnosisLoading] = useState(false); const [expandedDiagnoses, setExpandedDiagnoses] = useState>(new Set()); @@ -78,11 +79,25 @@ function Log(props: LogProps) { }, [updateCount]); // fetch AI diagnosis for the latest 3 unique errors/warnings + // only when installation status is red (2) or orange (1) useEffect(() => { - // combine errors and warnings, filter non-errors, sort by date+time descending, deduplicate + // skip diagnosis if status is not alarm (2) or warning (1) + if (props.status !== 1 && props.status !== 2) { + setDiagnoses([]); + return; + } + + // filter to last 24 hours only + const now = new Date(); + const cutoff = new Date(now.getTime() - 24 * 60 * 60 * 1000); + const ignore = new Set(['NoAlarm', '0', '']); const all = [...errors, ...warnings] - .filter(item => !ignore.has(item.description.trim())) + .filter(item => { + if (ignore.has(item.description.trim())) return false; + const itemDate = new Date(item.date + 'T' + item.time); + return itemDate >= cutoff; + }) .sort((a, b) => (b.date + ' ' + b.time).localeCompare(a.date + ' ' + a.time)); const seen = new Set(); @@ -112,16 +127,16 @@ function Log(props: LogProps) { .get(`/DiagnoseError?installationId=${props.id}&errorDescription=${encodeURIComponent(target.description)}`) .then((res: AxiosResponse) => { if (res.status === 204 || !res.data || !res.data.explanation) return null; - return { description: target.description, response: res.data }; + return { description: target.description, lastSeen: target.date + ' ' + target.time, response: res.data }; }) .catch(() => null) ) ).then(results => { - setDiagnoses(results.filter(r => r !== null) as { description: string; response: DiagnosticResponse }[]); + setDiagnoses(results.filter(r => r !== null) as { description: string; lastSeen: string; response: DiagnosticResponse }[]); }).finally(() => { setDiagnosisLoading(false); }); - }, [errors, warnings]); + }, [errors, warnings, props.status]); const handleErrorButtonPressed = () => { setErrorButtonPressed(!errorButtonPressed); @@ -476,10 +491,10 @@ function Log(props: LogProps) { - + {diag.description} - {diag.description} + Last seen: {diag.lastSeen} diff --git a/typescript/frontend-marios2/src/content/dashboards/SalidomoInstallations/Installation.tsx b/typescript/frontend-marios2/src/content/dashboards/SalidomoInstallations/Installation.tsx index e8ac17615..51d202da0 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SalidomoInstallations/Installation.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SalidomoInstallations/Installation.tsx @@ -346,6 +346,7 @@ function SalidomoInstallation(props: singleInstallationProps) { } /> diff --git a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx index d8621e97d..6a24ec99b 100644 --- a/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx +++ b/typescript/frontend-marios2/src/content/dashboards/SodiohomeInstallations/Installation.tsx @@ -478,6 +478,7 @@ function SodioHomeInstallation(props: singleInstallationProps) { } />