// ===================================================== // 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, } }];