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)
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);
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;
}
@ -784,16 +781,17 @@ public class Controller : ControllerBase
{
var testCases = new[]
{
// Sinexcel alarms
"Fan fault",
"Abnormal grid voltage",
"Battery 1not connected",
"Inverter power tube fault",
"Island protection",
// Growatt alarms
"Warning 300",
"Warning 500",
"Error 408",
// Sinexcel alarms (keys match SinexcelRecord.Api.cs property names)
"FanFault",
"AbnormalGridVoltage",
"Battery1NotConnected",
"InverterPowerTubeFault",
"IslandProtection",
// Growatt alarms (keys match GrowattWarningCode/GrowattErrorCode enum names)
"NoUtilityGrid",
"BatteryCommunicationFailure",
"BmsFault",
"OverTemperature",
"AFCI Fault",
// Unknown alarm (should return null - would call Mistral)
"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
.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)
.Distinct() // deduplicate — same error repeated adds no signal
.Take(5)

View File

@ -47,10 +47,9 @@ function Log(props: LogProps) {
const tokencontext = useContext(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 [diagnosisExpanded, setDiagnosisExpanded] = useState(false);
const [diagnosedError, setDiagnosedError] = useState<string>('');
const [expandedDiagnoses, setExpandedDiagnoses] = useState<Set<number>>(new Set());
useEffect(() => {
axiosConfig
@ -78,32 +77,50 @@ function Log(props: LogProps) {
});
}, [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(() => {
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) {
setDiagnosis(null);
setDiagnosedError('');
const seen = new Set<string>();
const targets: ErrorMessage[] = [];
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;
}
// already have a diagnosis for this exact description — skip
if (target.description === diagnosedError && diagnosis) return;
// check if the targets changed compared to what we already have
const currentDescs = diagnoses.map(d => d.description).join('|');
const newDescs = targets.map(t => t.description).join('|');
if (currentDescs === newDescs) return;
setDiagnosisLoading(true);
axiosConfig
.get(`/DiagnoseError?installationId=${props.id}&errorDescription=${encodeURIComponent(target.description)}`)
.then((res: AxiosResponse<DiagnosticResponse>) => {
setDiagnosis(res.data);
setDiagnosedError(target.description);
})
.catch(() => {
setDiagnosis(null);
})
.finally(() => {
setDiagnosisLoading(false);
});
Promise.all(
targets.map(target =>
axiosConfig
.get(`/DiagnoseError?installationId=${props.id}&errorDescription=${encodeURIComponent(target.description)}`)
.then((res: AxiosResponse<DiagnosticResponse>) => {
if (res.status === 204 || !res.data || !res.data.explanation) return null;
return { description: target.description, response: res.data };
})
.catch(() => null)
)
).then(results => {
setDiagnoses(results.filter(r => r !== null) as { description: string; response: DiagnosticResponse }[]);
}).finally(() => {
setDiagnosisLoading(false);
});
}, [errors, warnings]);
const handleErrorButtonPressed = () => {
@ -437,77 +454,81 @@ function Log(props: LogProps) {
<Container maxWidth="xl">
<Grid container>
{/* AI Diagnosis banner — shown when loading or a diagnosis is available */}
{(diagnosisLoading || diagnosis) && (
{/* AI Diagnosis banner — shown when loading or diagnoses are available */}
{diagnosisLoading && (
<Grid item xs={12} md={12}>
<Card sx={{ marginTop: '20px', borderLeft: '4px solid #1976d2' }}>
<Box sx={{ padding: '16px' }}>
{/* loading state */}
{diagnosisLoading && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<CircularProgress size={22} />
<Typography variant="body2" color="text.secondary">
<FormattedMessage id="ai_analyzing" defaultMessage="AI is analyzing..." />
</Typography>
</Box>
)}
{/* diagnosis result */}
{diagnosis && !diagnosisLoading && (
<>
<Box sx={{ display: 'flex', alignItems: 'center', gap: '8px', mb: 1 }}>
<Typography variant="subtitle2" fontWeight="bold" color="primary">
<FormattedMessage id="ai_diagnosis" defaultMessage="AI Diagnosis" />
</Typography>
<Typography variant="caption" color="text.secondary">
{diagnosedError}
</Typography>
</Box>
<Typography variant="body2">
{diagnosis.explanation}
</Typography>
<Button
size="small"
onClick={() => setDiagnosisExpanded(!diagnosisExpanded)}
sx={{ textTransform: 'none', p: 0, mt: 1 }}
>
<FormattedMessage
id={diagnosisExpanded ? 'ai_show_less' : 'ai_show_details'}
defaultMessage={diagnosisExpanded ? 'Show less' : 'Show details'}
/>
</Button>
{diagnosisExpanded && (
<Box sx={{ mt: 1 }}>
<Typography variant="caption" fontWeight="bold">
<FormattedMessage id="ai_likely_causes" defaultMessage="Likely causes:" />
</Typography>
<ul style={{ margin: '4px 0', paddingLeft: '20px' }}>
{diagnosis.causes.map((cause, i) => (
<li key={i}><Typography variant="caption">{cause}</Typography></li>
))}
</ul>
<Typography variant="caption" fontWeight="bold">
<FormattedMessage id="ai_next_steps" defaultMessage="Suggested next steps:" />
</Typography>
<ol style={{ margin: '4px 0', paddingLeft: '20px' }}>
{diagnosis.nextSteps.map((step, i) => (
<li key={i}><Typography variant="caption">{step}</Typography></li>
))}
</ol>
</Box>
)}
</>
)}
<Box sx={{ padding: '16px', display: 'flex', alignItems: 'center', gap: '12px' }}>
<CircularProgress size={22} />
<Typography variant="body2" color="text.secondary">
<FormattedMessage id="ai_analyzing" defaultMessage="AI is analyzing..." />
</Typography>
</Box>
</Card>
</Grid>
)}
{!diagnosisLoading && diagnoses.map((diag, idx) => {
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 }}>
<Typography variant="subtitle2" fontWeight="bold" color="primary">
<FormattedMessage id="ai_diagnosis" defaultMessage="AI Diagnosis" />
</Typography>
<Typography variant="caption" color="text.secondary">
{diag.description}
</Typography>
</Box>
<Typography variant="body2">
{diag.response.explanation}
</Typography>
<Button
size="small"
onClick={() => {
const next = new Set(expandedDiagnoses);
if (isExpanded) next.delete(idx); else next.add(idx);
setExpandedDiagnoses(next);
}}
sx={{ textTransform: 'none', p: 0, mt: 1 }}
>
<FormattedMessage
id={isExpanded ? 'ai_show_less' : 'ai_show_details'}
defaultMessage={isExpanded ? 'Show less' : 'Show details'}
/>
</Button>
{isExpanded && (
<Box sx={{ mt: 1 }}>
<Typography variant="caption" fontWeight="bold">
<FormattedMessage id="ai_likely_causes" defaultMessage="Likely causes:" />
</Typography>
<ul style={{ margin: '4px 0', paddingLeft: '20px' }}>
{diag.response.causes.map((cause, i) => (
<li key={i}><Typography variant="caption">{cause}</Typography></li>
))}
</ul>
<Typography variant="caption" fontWeight="bold">
<FormattedMessage id="ai_next_steps" defaultMessage="Suggested next steps:" />
</Typography>
<ol style={{ margin: '4px 0', paddingLeft: '20px' }}>
{diag.response.nextSteps.map((step, i) => (
<li key={i}><Typography variant="caption">{step}</Typography></li>
))}
</ol>
</Box>
)}
</Box>
</Card>
</Grid>
);
})}
<Grid item xs={12} md={12}>
<Button
variant="contained"