- Workflow n8n tdfInZns7cqo8fU2 actif (15 min): snapshot HA compact -> prompt FR -> LLM local Spark (modèle dynamique via /v1/models) avec fallback OpenRouter claude-haiku-4.5 (credential chiffré, clé hors git) -> validation stricte (enums, bornes, rejet total si invalide) -> helpers advisor_* + divergence + compteurs accord/divergence + logbook. - GARANTIE OMBRE structurelle: écritures limitées à advisor_*/logbook, vérifiée par tests/advisor/run.mjs (16 tests, bloquants au push). - Kill-switch input_boolean.evcs_advisor_enabled. - Extension R2 multi-workflows (lib-node-map WORKFLOWS[], build/extract/push par fichier, n8n-create.mjs, gen-advisor-workflow.mjs). - Helpers HA via package NFS + carte dashboard EVCS Master (backup fait). - 1re reco réelle: auto_solar_plus, cible 35%, accord avec le réel. - Fix sécurité n8n-push: plus de fallback d'id implicite vers le workflow EVCS.
95 lines
3.2 KiB
JavaScript
95 lines
3.2 KiB
JavaScript
// =====================================================
|
|
// 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 (<think>...</think>) et fences markdown
|
|
let text = content
|
|
.replace(/<think>[\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,
|
|
}
|
|
}];
|