// =====================================================
// EVCS ADVISOR SHADOW V1 — VALIDATE_PARSE
// Valide STRICTEMENT la sortie LLM (locale OU cloud, format OpenAI).
// Sortie invalide = REJET TOTAL (status invalid), jamais de clamp silencieux.
// =====================================================
const resp = $input.first().json;
const context = $('BUILD_PROMPT').first().json.context;
const STRATEGIES = ['auto_solar_plus', 'auto_solar', 'force_grid_now', 'force_charge_now', 'stop'];
const TONIGHT = ['oui', 'non', 'sans_objet'];
function fail(status, detail) {
return [{
json: {
status,
detail: (detail || '').toString().slice(0, 200),
advice: null,
modelUsed: (resp?.model || 'aucun').toString().slice(0, 60),
context,
}
}];
}
// 1. Réponse LLM présente ? (les deux backends ont échoué => item d'erreur n8n)
const content = resp?.choices?.[0]?.message?.content;
if (typeof content !== 'string' || !content.trim()) {
return fail('llm_offline', resp?.error?.message || resp?.error || 'aucune réponse LLM');
}
// 2. Nettoyage : balises de raisonnement (...) et fences markdown
let text = content
.replace(/[\s\S]*?<\/think>/gi, '')
.replace(/```(?:json)?/gi, '')
.trim();
// 3. Extraction du premier objet JSON équilibré
const start = text.indexOf('{');
if (start === -1) return fail('invalid', 'aucun JSON dans la réponse');
let depth = 0, end = -1;
for (let i = start; i < text.length; i++) {
if (text[i] === '{') depth++;
else if (text[i] === '}') { depth--; if (depth === 0) { end = i; break; } }
}
if (end === -1) return fail('invalid', 'JSON non terminé');
let obj;
try {
obj = JSON.parse(text.slice(start, end + 1));
} catch (e) {
return fail('invalid', `JSON invalide: ${e.message}`);
}
// 4. Validation stricte du schéma (enums + bornes)
const strategy = (obj.strategy ?? '').toString().trim().toLowerCase();
if (!STRATEGIES.includes(strategy)) return fail('invalid', `strategy hors enum: ${strategy}`);
const nightTarget = Number(obj.night_target_soc);
if (!Number.isFinite(nightTarget) || nightTarget < 22 || nightTarget > 90) {
return fail('invalid', `night_target_soc hors bornes: ${obj.night_target_soc}`);
}
const currentMax = Number(obj.current_max_a);
if (!Number.isFinite(currentMax) || currentMax < 6 || currentMax > 28) {
return fail('invalid', `current_max_a hors bornes: ${obj.current_max_a}`);
}
const tonight = (obj.ev_charge_tonight ?? '').toString().trim().toLowerCase();
if (!TONIGHT.includes(tonight)) return fail('invalid', `ev_charge_tonight hors enum: ${tonight}`);
const confidence = Number(obj.confidence);
if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) {
return fail('invalid', `confidence hors bornes: ${obj.confidence}`);
}
const reason = (obj.reason ?? '').toString().trim();
if (!reason) return fail('invalid', 'reason vide');
return [{
json: {
status: 'ok',
detail: '',
advice: {
strategy,
nightTargetSoc: Math.round(nightTarget),
currentMaxA: Math.round(currentMax),
evChargeTonight: tonight,
confidence: Math.round(confidence * 100) / 100,
reason: reason.slice(0, 250),
},
modelUsed: (resp.model || 'inconnu').toString().slice(0, 60),
context,
}
}];