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:
parent
0c918e86ae
commit
6460328eb0
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,30 +77,48 @@ 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);
|
||||
Promise.all(
|
||||
targets.map(target =>
|
||||
axiosConfig
|
||||
.get(`/DiagnoseError?installationId=${props.id}&errorDescription=${encodeURIComponent(target.description)}`)
|
||||
.then((res: AxiosResponse<DiagnosticResponse>) => {
|
||||
setDiagnosis(res.data);
|
||||
setDiagnosedError(target.description);
|
||||
if (res.status === 204 || !res.data || !res.data.explanation) return null;
|
||||
return { description: target.description, response: res.data };
|
||||
})
|
||||
.catch(() => {
|
||||
setDiagnosis(null);
|
||||
})
|
||||
.finally(() => {
|
||||
.catch(() => null)
|
||||
)
|
||||
).then(results => {
|
||||
setDiagnoses(results.filter(r => r !== null) as { description: string; response: DiagnosticResponse }[]);
|
||||
}).finally(() => {
|
||||
setDiagnosisLoading(false);
|
||||
});
|
||||
}, [errors, warnings]);
|
||||
|
|
@ -437,56 +454,61 @@ 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' }}>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* diagnosis result */}
|
||||
{diagnosis && !diagnosisLoading && (
|
||||
<>
|
||||
{!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">
|
||||
{diagnosedError}
|
||||
{diag.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2">
|
||||
{diagnosis.explanation}
|
||||
{diag.response.explanation}
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
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 }}
|
||||
>
|
||||
<FormattedMessage
|
||||
id={diagnosisExpanded ? 'ai_show_less' : 'ai_show_details'}
|
||||
defaultMessage={diagnosisExpanded ? 'Show less' : 'Show details'}
|
||||
id={isExpanded ? 'ai_show_less' : 'ai_show_details'}
|
||||
defaultMessage={isExpanded ? 'Show less' : 'Show details'}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{diagnosisExpanded && (
|
||||
{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' }}>
|
||||
{diagnosis.causes.map((cause, i) => (
|
||||
{diag.response.causes.map((cause, i) => (
|
||||
<li key={i}><Typography variant="caption">{cause}</Typography></li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
@ -495,18 +517,17 @@ function Log(props: LogProps) {
|
|||
<FormattedMessage id="ai_next_steps" defaultMessage="Suggested next steps:" />
|
||||
</Typography>
|
||||
<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>
|
||||
))}
|
||||
</ol>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
</Grid>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
|
||||
<Grid item xs={12} md={12}>
|
||||
<Button
|
||||
|
|
|
|||
Loading…
Reference in New Issue