Files
EVCS/src/advisor/VALIDATE_PARSE.js
Tallagrand Thierry 63d56bf284 Agent IA planificateur EVCS en MODE OMBRE (Advisor Shadow V1)
- 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.
2026-08-07 22:42:06 +02:00

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