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 <noreply@anthropic.com>
This commit is contained in:
Yinyin Liu 2026-02-17 12:16:35 +01:00
parent 9a723c0a6f
commit c076d55407
6 changed files with 105 additions and 11 deletions

View File

@ -821,6 +821,47 @@ public class Controller : ControllerBase
}); });
} }
/// <summary>
/// Test endpoint for the full AI diagnostic flow (knowledge base + Mistral API).
/// No auth required. Remove before production.
/// Usage: GET /api/TestDiagnoseError?errorDescription=SomeAlarm
/// </summary>
[HttpGet(nameof(TestDiagnoseError))]
public async Task<ActionResult> 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))] [HttpPut(nameof(UpdateFolder))]
public ActionResult<Folder> UpdateFolder([FromBody] Folder folder, Token authToken) public ActionResult<Folder> UpdateFolder([FromBody] Folder folder, Token authToken)
{ {

View File

@ -88,6 +88,28 @@ public static class DiagnosticService
return response; return response;
} }
// ── test helper (no DB dependency) ─────────────────────────────
/// <summary>
/// Calls Mistral directly with a generic prompt. For testing only - no DB lookup.
/// </summary>
public static async Task<DiagnosticResponse?> 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<string>());
var response = await CallMistralAsync(prompt);
if (response is not null)
Cache.TryAdd(errorDescription, response);
return response;
}
// ── prompt ────────────────────────────────────────────────────── // ── prompt ──────────────────────────────────────────────────────
private static string BuildPrompt(string errorDescription, string productName, List<string> recentErrors) private static string BuildPrompt(string errorDescription, string productName, List<string> recentErrors)
@ -102,9 +124,12 @@ These are lithium-ion BESS units with a BMS, PV inverter, and grid inverter.
Error: {errorDescription} Error: {errorDescription}
Other recent errors: {recentList} 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: 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; 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 // parse the JSON the model produced
var diagnostic = JsonConvert.DeserializeObject<DiagnosticResponse>(content); var diagnostic = JsonConvert.DeserializeObject<DiagnosticResponse>(json);
return diagnostic; return diagnostic;
} }
catch (FlurlHttpException httpEx) catch (FlurlHttpException httpEx)

View File

@ -461,6 +461,7 @@ function Installation(props: singleInstallationProps) {
<Log <Log
errorLoadingS3Data={errorLoadingS3Data} errorLoadingS3Data={errorLoadingS3Data}
id={props.current_installation.id} id={props.current_installation.id}
status={props.current_installation.status}
></Log> ></Log>
} }
/> />

View File

@ -27,6 +27,7 @@ import Checkbox from '@mui/material/Checkbox';
interface LogProps { interface LogProps {
errorLoadingS3Data: boolean; errorLoadingS3Data: boolean;
id: number; id: number;
status?: number;
} }
function Log(props: LogProps) { function Log(props: LogProps) {
@ -47,7 +48,7 @@ function Log(props: LogProps) {
const tokencontext = useContext(TokenContext); const tokencontext = useContext(TokenContext);
const { removeToken } = 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 [diagnosisLoading, setDiagnosisLoading] = useState(false);
const [expandedDiagnoses, setExpandedDiagnoses] = useState<Set<number>>(new Set()); const [expandedDiagnoses, setExpandedDiagnoses] = useState<Set<number>>(new Set());
@ -78,11 +79,25 @@ function Log(props: LogProps) {
}, [updateCount]); }, [updateCount]);
// fetch AI diagnosis for the latest 3 unique errors/warnings // fetch AI diagnosis for the latest 3 unique errors/warnings
// only when installation status is red (2) or orange (1)
useEffect(() => { 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 ignore = new Set(['NoAlarm', '0', '']);
const all = [...errors, ...warnings] 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)); .sort((a, b) => (b.date + ' ' + b.time).localeCompare(a.date + ' ' + a.time));
const seen = new Set<string>(); const seen = new Set<string>();
@ -112,16 +127,16 @@ function Log(props: LogProps) {
.get(`/DiagnoseError?installationId=${props.id}&errorDescription=${encodeURIComponent(target.description)}`) .get(`/DiagnoseError?installationId=${props.id}&errorDescription=${encodeURIComponent(target.description)}`)
.then((res: AxiosResponse<DiagnosticResponse>) => { .then((res: AxiosResponse<DiagnosticResponse>) => {
if (res.status === 204 || !res.data || !res.data.explanation) return null; 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) .catch(() => null)
) )
).then(results => { ).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(() => { }).finally(() => {
setDiagnosisLoading(false); setDiagnosisLoading(false);
}); });
}, [errors, warnings]); }, [errors, warnings, props.status]);
const handleErrorButtonPressed = () => { const handleErrorButtonPressed = () => {
setErrorButtonPressed(!errorButtonPressed); setErrorButtonPressed(!errorButtonPressed);
@ -476,10 +491,10 @@ function Log(props: LogProps) {
<Box sx={{ padding: '16px' }}> <Box sx={{ padding: '16px' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: '8px', mb: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: '8px', mb: 1 }}>
<Typography variant="subtitle2" fontWeight="bold" color="primary"> <Typography variant="subtitle2" fontWeight="bold" color="primary">
<FormattedMessage id="ai_diagnosis" defaultMessage="AI Diagnosis" /> {diag.description}
</Typography> </Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
{diag.description} Last seen: {diag.lastSeen}
</Typography> </Typography>
</Box> </Box>

View File

@ -346,6 +346,7 @@ function SalidomoInstallation(props: singleInstallationProps) {
<Log <Log
errorLoadingS3Data={errorLoadingS3Data} errorLoadingS3Data={errorLoadingS3Data}
id={props.current_installation.id} id={props.current_installation.id}
status={props.current_installation.status}
></Log> ></Log>
} }
/> />

View File

@ -478,6 +478,7 @@ function SodioHomeInstallation(props: singleInstallationProps) {
<Log <Log
errorLoadingS3Data={errorLoadingS3Data} errorLoadingS3Data={errorLoadingS3Data}
id={props.current_installation.id} id={props.current_installation.id}
status={props.current_installation.status}
></Log> ></Log>
} }
/> />