Safe mode for AI diagnostics, align alarm keys with device enums, and multi-diagnosis frontend

- Remove API key gate so knowledge base works without Mistral key
- Return 204 No Content instead of 500 when no diagnosis available
- Rewrite AlarmKnowledgeBase keys to match Sinexcel property names and Growatt enum names
- Fix SQLite OrderBy crash in DiagnosticService
- Frontend: show latest 3 unique alarms with independent expand/collapse and handle 204

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yinyin Liu 2026-02-12 11:32:49 +01:00
parent 0c918e86ae
commit 6460328eb0
4 changed files with 397 additions and 376 deletions

View File

@ -763,13 +763,10 @@ public class Controller : ControllerBase
installation.Product != (int)ProductType.SodiStoreMax) installation.Product != (int)ProductType.SodiStoreMax)
return BadRequest("AI diagnostics not available for this product."); return BadRequest("AI diagnostics not available for this product.");
if (!DiagnosticService.IsEnabled)
return StatusCode(503, "AI diagnostics not configured.");
var result = await DiagnosticService.DiagnoseAsync(installationId, errorDescription); var result = await DiagnosticService.DiagnoseAsync(installationId, errorDescription);
if (result is null) if (result is null)
return StatusCode(500, "Diagnosis failed please try again later."); return NoContent(); // no diagnosis available (not in knowledge base, no API key)
return result; return result;
} }
@ -784,16 +781,17 @@ public class Controller : ControllerBase
{ {
var testCases = new[] var testCases = new[]
{ {
// Sinexcel alarms // Sinexcel alarms (keys match SinexcelRecord.Api.cs property names)
"Fan fault", "FanFault",
"Abnormal grid voltage", "AbnormalGridVoltage",
"Battery 1not connected", "Battery1NotConnected",
"Inverter power tube fault", "InverterPowerTubeFault",
"Island protection", "IslandProtection",
// Growatt alarms // Growatt alarms (keys match GrowattWarningCode/GrowattErrorCode enum names)
"Warning 300", "NoUtilityGrid",
"Warning 500", "BatteryCommunicationFailure",
"Error 408", "BmsFault",
"OverTemperature",
"AFCI Fault", "AFCI Fault",
// Unknown alarm (should return null - would call Mistral) // Unknown alarm (should return null - would call Mistral)
"Some unknown alarm XYZ123" "Some unknown alarm XYZ123"

File diff suppressed because it is too large Load Diff

View File

@ -68,7 +68,9 @@ public static class DiagnosticService
var recentDescriptions = Db.Errors var recentDescriptions = Db.Errors
.Where(e => e.InstallationId == installationId) .Where(e => e.InstallationId == installationId)
.OrderByDescending(e => e.Date + " " + e.Time) // Date/Time stored as strings in DB .OrderByDescending(e => e.Date)
.ThenByDescending(e => e.Time)
.ToList() // materialize before LINQ-to-objects ops
.Select(e => e.Description) .Select(e => e.Description)
.Distinct() // deduplicate — same error repeated adds no signal .Distinct() // deduplicate — same error repeated adds no signal
.Take(5) .Take(5)

View File

@ -47,10 +47,9 @@ function Log(props: LogProps) {
const tokencontext = useContext(TokenContext); const tokencontext = useContext(TokenContext);
const { removeToken } = tokencontext; const { removeToken } = tokencontext;
const [diagnosis, setDiagnosis] = useState<DiagnosticResponse | null>(null); const [diagnoses, setDiagnoses] = useState<{ description: string; response: DiagnosticResponse }[]>([]);
const [diagnosisLoading, setDiagnosisLoading] = useState(false); const [diagnosisLoading, setDiagnosisLoading] = useState(false);
const [diagnosisExpanded, setDiagnosisExpanded] = useState(false); const [expandedDiagnoses, setExpandedDiagnoses] = useState<Set<number>>(new Set());
const [diagnosedError, setDiagnosedError] = useState<string>('');
useEffect(() => { useEffect(() => {
axiosConfig axiosConfig
@ -78,30 +77,48 @@ function Log(props: LogProps) {
}); });
}, [updateCount]); }, [updateCount]);
// fetch AI diagnosis for the first unseen error (or warning if no unseen errors) // fetch AI diagnosis for the latest 3 unique errors/warnings
useEffect(() => { useEffect(() => {
const target = errors.find(e => !e.seen) || warnings.find(w => !w.seen); // combine errors and warnings, filter non-errors, sort by date+time descending, deduplicate
const ignore = new Set(['NoAlarm', '0', '']);
const all = [...errors, ...warnings]
.filter(item => !ignore.has(item.description.trim()))
.sort((a, b) => (b.date + ' ' + b.time).localeCompare(a.date + ' ' + a.time));
if (!target) { const seen = new Set<string>();
setDiagnosis(null); const targets: ErrorMessage[] = [];
setDiagnosedError(''); for (const item of all) {
if (!seen.has(item.description)) {
seen.add(item.description);
targets.push(item);
if (targets.length >= 3) break;
}
}
if (targets.length === 0) {
setDiagnoses([]);
return; return;
} }
// already have a diagnosis for this exact description — skip // check if the targets changed compared to what we already have
if (target.description === diagnosedError && diagnosis) return; const currentDescs = diagnoses.map(d => d.description).join('|');
const newDescs = targets.map(t => t.description).join('|');
if (currentDescs === newDescs) return;
setDiagnosisLoading(true); setDiagnosisLoading(true);
Promise.all(
targets.map(target =>
axiosConfig axiosConfig
.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>) => {
setDiagnosis(res.data); if (res.status === 204 || !res.data || !res.data.explanation) return null;
setDiagnosedError(target.description); return { description: target.description, response: res.data };
}) })
.catch(() => { .catch(() => null)
setDiagnosis(null); )
}) ).then(results => {
.finally(() => { setDiagnoses(results.filter(r => r !== null) as { description: string; response: DiagnosticResponse }[]);
}).finally(() => {
setDiagnosisLoading(false); setDiagnosisLoading(false);
}); });
}, [errors, warnings]); }, [errors, warnings]);
@ -437,56 +454,61 @@ function Log(props: LogProps) {
<Container maxWidth="xl"> <Container maxWidth="xl">
<Grid container> <Grid container>
{/* AI Diagnosis banner — shown when loading or a diagnosis is available */} {/* AI Diagnosis banner — shown when loading or diagnoses are available */}
{(diagnosisLoading || diagnosis) && ( {diagnosisLoading && (
<Grid item xs={12} md={12}> <Grid item xs={12} md={12}>
<Card sx={{ marginTop: '20px', borderLeft: '4px solid #1976d2' }}> <Card sx={{ marginTop: '20px', borderLeft: '4px solid #1976d2' }}>
<Box sx={{ padding: '16px' }}> <Box sx={{ padding: '16px', display: 'flex', alignItems: 'center', gap: '12px' }}>
{/* loading state */}
{diagnosisLoading && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<CircularProgress size={22} /> <CircularProgress size={22} />
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
<FormattedMessage id="ai_analyzing" defaultMessage="AI is analyzing..." /> <FormattedMessage id="ai_analyzing" defaultMessage="AI is analyzing..." />
</Typography> </Typography>
</Box> </Box>
</Card>
</Grid>
)} )}
{/* diagnosis result */} {!diagnosisLoading && diagnoses.map((diag, idx) => {
{diagnosis && !diagnosisLoading && ( const isExpanded = expandedDiagnoses.has(idx);
<> return (
<Grid item xs={12} md={12} key={idx}>
<Card sx={{ marginTop: idx === 0 ? '20px' : '10px', borderLeft: '4px solid #1976d2' }}>
<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" /> <FormattedMessage id="ai_diagnosis" defaultMessage="AI Diagnosis" />
</Typography> </Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
{diagnosedError} {diag.description}
</Typography> </Typography>
</Box> </Box>
<Typography variant="body2"> <Typography variant="body2">
{diagnosis.explanation} {diag.response.explanation}
</Typography> </Typography>
<Button <Button
size="small" size="small"
onClick={() => setDiagnosisExpanded(!diagnosisExpanded)} onClick={() => {
const next = new Set(expandedDiagnoses);
if (isExpanded) next.delete(idx); else next.add(idx);
setExpandedDiagnoses(next);
}}
sx={{ textTransform: 'none', p: 0, mt: 1 }} sx={{ textTransform: 'none', p: 0, mt: 1 }}
> >
<FormattedMessage <FormattedMessage
id={diagnosisExpanded ? 'ai_show_less' : 'ai_show_details'} id={isExpanded ? 'ai_show_less' : 'ai_show_details'}
defaultMessage={diagnosisExpanded ? 'Show less' : 'Show details'} defaultMessage={isExpanded ? 'Show less' : 'Show details'}
/> />
</Button> </Button>
{diagnosisExpanded && ( {isExpanded && (
<Box sx={{ mt: 1 }}> <Box sx={{ mt: 1 }}>
<Typography variant="caption" fontWeight="bold"> <Typography variant="caption" fontWeight="bold">
<FormattedMessage id="ai_likely_causes" defaultMessage="Likely causes:" /> <FormattedMessage id="ai_likely_causes" defaultMessage="Likely causes:" />
</Typography> </Typography>
<ul style={{ margin: '4px 0', paddingLeft: '20px' }}> <ul style={{ margin: '4px 0', paddingLeft: '20px' }}>
{diagnosis.causes.map((cause, i) => ( {diag.response.causes.map((cause, i) => (
<li key={i}><Typography variant="caption">{cause}</Typography></li> <li key={i}><Typography variant="caption">{cause}</Typography></li>
))} ))}
</ul> </ul>
@ -495,18 +517,17 @@ function Log(props: LogProps) {
<FormattedMessage id="ai_next_steps" defaultMessage="Suggested next steps:" /> <FormattedMessage id="ai_next_steps" defaultMessage="Suggested next steps:" />
</Typography> </Typography>
<ol style={{ margin: '4px 0', paddingLeft: '20px' }}> <ol style={{ margin: '4px 0', paddingLeft: '20px' }}>
{diagnosis.nextSteps.map((step, i) => ( {diag.response.nextSteps.map((step, i) => (
<li key={i}><Typography variant="caption">{step}</Typography></li> <li key={i}><Typography variant="caption">{step}</Typography></li>
))} ))}
</ol> </ol>
</Box> </Box>
)} )}
</>
)}
</Box> </Box>
</Card> </Card>
</Grid> </Grid>
)} );
})}
<Grid item xs={12} md={12}> <Grid item xs={12} md={12}>
<Button <Button