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:
parent
9a723c0a6f
commit
c076d55407
|
|
@ -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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue