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.
This commit is contained in:
70
src/advisor/BUILD_HA_CALLS.js
Normal file
70
src/advisor/BUILD_HA_CALLS.js
Normal file
@@ -0,0 +1,70 @@
|
||||
// =====================================================
|
||||
// EVCS ADVISOR SHADOW V1 — BUILD_HA_CALLS
|
||||
// Écrit les helpers advisor_* + divergence + compteurs + logbook.
|
||||
// GARANTIE MODE OMBRE : ce node ne produit QUE des écritures vers
|
||||
// des entités advisor_* et le logbook. AUCUN switch, AUCUNE stratégie
|
||||
// réelle, AUCUNE commande borne. Ne pas ajouter d'autres entités ici.
|
||||
// =====================================================
|
||||
|
||||
const { status, detail, advice, modelUsed, context } = $input.first().json;
|
||||
|
||||
function stampParis() {
|
||||
const p = new Intl.DateTimeFormat('fr-FR', {
|
||||
timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
}).formatToParts(new Date());
|
||||
const g = t => p.find(x => x.type === t)?.value || '';
|
||||
return `${g('day')}/${g('month')} ${g('hour')}:${g('minute')}`;
|
||||
}
|
||||
|
||||
const calls = [];
|
||||
function push(domain, service, data) {
|
||||
calls.push({ json: { domain, service, data } });
|
||||
}
|
||||
function setText(entity, value) {
|
||||
push('input_text', 'set_value', { entity_id: entity, value: (value ?? '').toString().slice(0, 250) });
|
||||
}
|
||||
function setNumber(entity, value) {
|
||||
push('input_number', 'set_value', { entity_id: entity, value });
|
||||
}
|
||||
|
||||
// Statut + horodatage + modèle : écrits à CHAQUE run
|
||||
setText('input_text.evcs_advisor_status', status + (detail ? `: ${detail}` : ''));
|
||||
setText('input_text.evcs_advisor_last_run', stampParis());
|
||||
setText('input_text.evcs_advisor_model', modelUsed);
|
||||
|
||||
if (status === 'ok' && advice) {
|
||||
// Recommandations
|
||||
setText('input_text.evcs_advisor_strategy', advice.strategy);
|
||||
setNumber('input_number.evcs_advisor_night_target_soc', advice.nightTargetSoc);
|
||||
setNumber('input_number.evcs_advisor_current_max_a', advice.currentMaxA);
|
||||
setText('input_text.evcs_advisor_ev_charge_tonight', advice.evChargeTonight);
|
||||
setText('input_text.evcs_advisor_reason', `[${Math.round(advice.confidence * 100)}%] ${advice.reason}`);
|
||||
|
||||
// Divergence vs réel (stratégie + cible nuit)
|
||||
const diffs = [];
|
||||
if (advice.strategy !== context.userStrategy) {
|
||||
diffs.push(`stratégie: agent=${advice.strategy} ≠ réel=${context.userStrategy}`);
|
||||
}
|
||||
if (Math.abs(advice.nightTargetSoc - context.nightTargetActual) > 10) {
|
||||
diffs.push(`cible nuit: agent=${advice.nightTargetSoc}% vs réel=${context.nightTargetActual}%`);
|
||||
}
|
||||
const divergenceText = diffs.length ? diffs.join(' | ') : 'accord avec le réel';
|
||||
setText('input_text.evcs_advisor_divergence', divergenceText);
|
||||
|
||||
// Compteurs accord/divergence sur la stratégie (base des critères de bascule)
|
||||
if (advice.strategy === context.userStrategy) {
|
||||
setNumber('input_number.evcs_advisor_agree_count', context.agreeCount + 1);
|
||||
} else {
|
||||
setNumber('input_number.evcs_advisor_diverge_count', context.divergeCount + 1);
|
||||
}
|
||||
|
||||
// Trace logbook pour l'historique long terme
|
||||
push('logbook', 'log', {
|
||||
name: 'EVCS Advisor (ombre)',
|
||||
message: `${advice.strategy} | cible nuit ${advice.nightTargetSoc}% | ${advice.evChargeTonight === 'oui' ? 'charge VE cette nuit' : advice.evChargeTonight === 'non' ? 'pas de charge nuit' : 'VE sans objet'} | ${divergenceText} | ${advice.reason}`,
|
||||
entity_id: 'input_text.evcs_advisor_strategy',
|
||||
});
|
||||
}
|
||||
|
||||
return calls;
|
||||
113
src/advisor/BUILD_PROMPT.js
Normal file
113
src/advisor/BUILD_PROMPT.js
Normal file
@@ -0,0 +1,113 @@
|
||||
// =====================================================
|
||||
// EVCS ADVISOR SHADOW V1 — BUILD_PROMPT
|
||||
// Construit le prompt LLM à partir du snapshot HA compact.
|
||||
// MODE OMBRE : ce workflow n'écrit que des helpers advisor_*.
|
||||
// =====================================================
|
||||
|
||||
function num(v, d) {
|
||||
const n = Number.parseFloat(v);
|
||||
return Number.isFinite(n) ? n : d;
|
||||
}
|
||||
function onOff(v) {
|
||||
return (v ?? 'off').toString().trim().toLowerCase() === 'on';
|
||||
}
|
||||
|
||||
const raw = $input.first().json;
|
||||
const input = typeof raw.data === 'string' ? JSON.parse(raw.data) : (raw.data || raw);
|
||||
|
||||
const ctx = {
|
||||
advisorEnabled: onOff(input.advisor_enabled),
|
||||
agreeCount: num(input.advisor_agree_count, 0),
|
||||
divergeCount: num(input.advisor_diverge_count, 0),
|
||||
prevAdvisorStrategy: (input.advisor_prev_strategy || 'unknown').toString(),
|
||||
|
||||
soc: num(input.soc_percent, NaN),
|
||||
batteryCurrentA: num(input.battery_current_a, NaN),
|
||||
pvW: num(input.pv_power_w, 0),
|
||||
pvAvg10mW: num(input.pv_power_10m_avg_w, 0),
|
||||
houseW: num(input.house_ac_power_w, 0),
|
||||
gridOn: onOff(input.grid_state),
|
||||
acinAvailable: num(input.acin1_available, 0) === 1,
|
||||
|
||||
vehicleConnected: onOff(input.evcs_vehicle_connected),
|
||||
evcsCharging: onOff(input.evcs_charging),
|
||||
evcsCurrentA: num(input.evcs_current_a, 0),
|
||||
surplusState: (input.evcs_surplus_state || '').toString(),
|
||||
sourceMode: (input.evcs_source_mode || '').toString(),
|
||||
|
||||
userStrategy: (input.user_strategy || 'auto_solar_plus').toString().trim().toLowerCase(),
|
||||
allowGridHcNight: onOff(input.allow_grid_hc_night),
|
||||
allowGridHcMidday: onOff(input.allow_grid_hc_midday),
|
||||
nightTargetActual: num(input.night_target_soc_actual, 35),
|
||||
currentMaxAutoA: num(input.current_max_auto_a, 22),
|
||||
plafondEdfOnA: num(input.plafond_edf_on_a, 20),
|
||||
|
||||
departureTime: (input.departure_time || '').toString(),
|
||||
departurePriority: onOff(input.departure_priority_enabled),
|
||||
requiredEnergyKwh: num(input.required_energy_kwh, 0),
|
||||
|
||||
todayRemainingKwh: num(input.today_remaining_kwh, 0),
|
||||
tomorrowKwh: num(input.tomorrow_kwh, 0),
|
||||
};
|
||||
|
||||
// Heure Paris
|
||||
const parisFmt = new Intl.DateTimeFormat('fr-FR', {
|
||||
timeZone: 'Europe/Paris',
|
||||
weekday: 'long', day: '2-digit', month: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
});
|
||||
const nowParis = parisFmt.format(new Date());
|
||||
|
||||
const systemPrompt = [
|
||||
'Tu es le PLANIFICATEUR ÉNERGIE d\'une maison autonome française. Tu es en MODE OBSERVATION :',
|
||||
'tes recommandations sont enregistrées pour comparaison, elles ne pilotent rien.',
|
||||
'',
|
||||
'INSTALLATION : Victron ESS SANS injection réseau. Batterie 17,5 kWh utile (plancher sécurité 22%).',
|
||||
'PV ~9 kWc. Borne EVCS 6-28 A. Réseau EDF commutable (cher en HP, bon marché en HC).',
|
||||
'Heures creuses : 01:24-07:24 et 12:24-14:24. Véhicules : Jeep, moto Zéro, VW hybride (arrive en fin d\'après-midi).',
|
||||
'',
|
||||
'STRATÉGIES POSSIBLES (input_select) :',
|
||||
'- auto_solar_plus : automatique, solaire prioritaire + réseau HC la nuit si véhicule branché (défaut recommandé)',
|
||||
'- auto_solar : automatique, solaire strict (jamais de réseau)',
|
||||
'- force_grid_now : charge VE immédiate sur réseau EDF (à réserver aux urgences/gros besoins)',
|
||||
'- force_charge_now : charge locale immédiate quelle que soit la source (secours manuel)',
|
||||
'- stop : tout couper',
|
||||
'',
|
||||
'PRINCIPES : maximiser l\'autoconsommation solaire ; réseau seulement en HC ou urgence ;',
|
||||
'ne jamais viser un SOC matin < 22% ; forecast demain fort (>=18 kWh) => cible matin basse (~35%),',
|
||||
'moyen (>=10) => ~55%, faible => ~85%. Plafond courant : 20 A max si EDF ON, sinon 22 A auto.',
|
||||
'',
|
||||
'Réponds UNIQUEMENT avec un objet JSON valide, sans texte autour, au format EXACT :',
|
||||
'{"strategy":"auto_solar_plus|auto_solar|force_grid_now|force_charge_now|stop",',
|
||||
'"night_target_soc":<entier 22-90>,"current_max_a":<entier 6-28>,',
|
||||
'"ev_charge_tonight":"oui|non|sans_objet","confidence":<0.0-1.0>,',
|
||||
'"reason":"une phrase courte en français (max 200 caractères)"}',
|
||||
].join('\n');
|
||||
|
||||
const userPrompt = [
|
||||
`Nous sommes ${nowParis} (heure de Paris).`,
|
||||
'',
|
||||
'ÉTAT ACTUEL :',
|
||||
`- Batterie : SOC ${ctx.soc}%, courant ${ctx.batteryCurrentA} A (positif = charge)`,
|
||||
`- PV : ${Math.round(ctx.pvW)} W instantané, ${Math.round(ctx.pvAvg10mW)} W moyenne 10 min`,
|
||||
`- Maison : ${Math.round(ctx.houseW)} W | Réseau EDF : ${ctx.gridOn ? 'ON' : 'OFF'} (AC-IN ${ctx.acinAvailable ? 'présent' : 'absent'})`,
|
||||
`- Véhicule : ${ctx.vehicleConnected ? 'BRANCHÉ' : 'non branché'}${ctx.evcsCharging ? `, en charge à ${ctx.evcsCurrentA} A` : ''}`,
|
||||
`- État contrôleur : ${ctx.surplusState} | Source : ${ctx.sourceMode}`,
|
||||
`- Stratégie réelle actuelle : ${ctx.userStrategy}`,
|
||||
`- Besoin véhicule : ${ctx.requiredEnergyKwh} kWh, départ ${ctx.departureTime}${ctx.departurePriority ? ' (priorité départ ON)' : ''}`,
|
||||
`- Prévision solaire : ${ctx.todayRemainingKwh} kWh restant aujourd'hui, ${ctx.tomorrowKwh} kWh demain`,
|
||||
`- Cible SOC matin actuelle : ${ctx.nightTargetActual}% | HC nuit autorisé : ${ctx.allowGridHcNight ? 'oui' : 'non'}`,
|
||||
'',
|
||||
'Quelle est ta recommandation ? JSON uniquement.',
|
||||
].join('\n');
|
||||
|
||||
return [{
|
||||
json: {
|
||||
enabled: ctx.advisorEnabled,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
context: ctx,
|
||||
}
|
||||
}];
|
||||
34
src/advisor/HA_SNAPSHOT_TEMPLATE.json.tpl
Normal file
34
src/advisor/HA_SNAPSHOT_TEMPLATE.json.tpl
Normal file
@@ -0,0 +1,34 @@
|
||||
{{ {
|
||||
"advisor_enabled": states("input_boolean.evcs_advisor_enabled"),
|
||||
"advisor_agree_count": states("input_number.evcs_advisor_agree_count"),
|
||||
"advisor_diverge_count": states("input_number.evcs_advisor_diverge_count"),
|
||||
"advisor_prev_strategy": states("input_text.evcs_advisor_strategy"),
|
||||
|
||||
"soc_percent": states("sensor.victron_battery_soc"),
|
||||
"battery_current_a": states("sensor.victron_battery_current"),
|
||||
"pv_power_w": states("sensor.victron_pv_power_system"),
|
||||
"pv_power_10m_avg_w": states("sensor.victron_pv_power_10m_avg"),
|
||||
"house_ac_power_w": states("sensor.victron_consumption_power_l1"),
|
||||
"grid_state": states("switch.edf_general_ok_switch"),
|
||||
"acin1_available": states("sensor.victron_ac_in1_available"),
|
||||
|
||||
"evcs_vehicle_connected": states("binary_sensor.victron_evcs_vehicle_connected"),
|
||||
"evcs_charging": states("switch.victron_evcs_charging"),
|
||||
"evcs_current_a": states("sensor.victron_evcs_current"),
|
||||
"evcs_surplus_state": states("input_text.victron_evcs_surplus_state"),
|
||||
"evcs_source_mode": states("input_text.victron_evcs_source_mode"),
|
||||
|
||||
"user_strategy": states("input_select.victron_evcs_strategy"),
|
||||
"allow_grid_hc_night": states("input_boolean.victron_evcs_allow_grid_hc_night"),
|
||||
"allow_grid_hc_midday": states("input_boolean.victron_evcs_allow_grid_hc_midday"),
|
||||
"night_target_soc_actual": states("input_number.victron_evcs_night_target_soc"),
|
||||
"current_max_auto_a": states("input_number.victron_evcs_current_max_auto_a"),
|
||||
"plafond_edf_on_a": states("input_number.evcs_plafond_courant_edf_on"),
|
||||
|
||||
"departure_time": states("input_datetime.victron_evcs_departure_time"),
|
||||
"departure_priority_enabled": states("input_boolean.victron_evcs_departure_priority_enabled"),
|
||||
"required_energy_kwh": states("input_number.victron_evcs_required_energy_kwh"),
|
||||
|
||||
"today_remaining_kwh": states("sensor.energy_production_today_remaining"),
|
||||
"tomorrow_kwh": states("sensor.energy_production_tomorrow")
|
||||
} | tojson }}
|
||||
94
src/advisor/VALIDATE_PARSE.js
Normal file
94
src/advisor/VALIDATE_PARSE.js
Normal file
@@ -0,0 +1,94 @@
|
||||
// =====================================================
|
||||
// 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,
|
||||
}
|
||||
}];
|
||||
Reference in New Issue
Block a user