From 63d56bf28417df3a1af8542fba279831546ec902 Mon Sep 17 00:00:00 2001 From: Tallagrand Thierry Date: Fri, 7 Aug 2026 22:42:06 +0200 Subject: [PATCH] Agent IA planificateur EVCS en MODE OMBRE (Advisor Shadow V1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- HANDOFF.md | 58 +++ src/advisor/BUILD_HA_CALLS.js | 70 ++++ src/advisor/BUILD_PROMPT.js | 113 ++++++ src/advisor/HA_SNAPSHOT_TEMPLATE.json.tpl | 34 ++ src/advisor/VALIDATE_PARSE.js | 94 +++++ tests/advisor/run.mjs | 123 +++++++ tools/gen-advisor-workflow.mjs | 214 +++++++++++ tools/lib-node-map.mjs | 50 ++- tools/n8n-build.mjs | 64 ++-- tools/n8n-create.mjs | 37 ++ tools/n8n-extract.mjs | 34 +- tools/n8n-push.mjs | 39 +- workflows/evcs-advisor-shadow-v1.json | 421 ++++++++++++++++++++++ 13 files changed, 1282 insertions(+), 69 deletions(-) create mode 100644 src/advisor/BUILD_HA_CALLS.js create mode 100644 src/advisor/BUILD_PROMPT.js create mode 100644 src/advisor/HA_SNAPSHOT_TEMPLATE.json.tpl create mode 100644 src/advisor/VALIDATE_PARSE.js create mode 100644 tests/advisor/run.mjs create mode 100644 tools/gen-advisor-workflow.mjs create mode 100644 tools/n8n-create.mjs create mode 100644 workflows/evcs-advisor-shadow-v1.json diff --git a/HANDOFF.md b/HANDOFF.md index 67c0189..deab532 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -662,3 +662,61 @@ JAMAIS d'édition directe du JSON ou de l'UI n8n (écrasée au prochain push). - Suivant : agent IA planificateur en MODE OMBRE (jamais dans la boucle 30 s) — helpers advisor_*, validation bornée des sorties, kill-switch, cf. HANDOFF §13/§14. +## 16. Session du 07/08/2026 (nuit) — Agent IA planificateur en MODE OMBRE + +### Ce qui tourne désormais +Workflow n8n **"EVCS Advisor Shadow V1"** (id `tdfInZns7cqo8fU2`, actif, 15 min) : +``` +Schedule 15min → HA Snapshot compact (template Jinja {{ {...} | tojson }}) +→ BUILD_PROMPT (kill-switch input_boolean.evcs_advisor_enabled, prompt FR ~4K chars) +→ IF_ENABLED → LLM_LOCAL_MODELS (GET 10.10.10.8:8000/v1/models, 5s) +→ LLM_LOCAL (modèle DYNAMIQUE = celui chargé sur la Spark, max_tokens 6000) +→ IF_LOCAL_OK → sinon LLM_CLOUD (OpenRouter anthropic/claude-haiku-4.5, + credential chiffré "OpenRouter Advisor" id eOM9xofDHT0KMoeY — clé JAMAIS dans git) +→ VALIDATE_PARSE (strip , JSON strict, enums+bornes, invalide = REJET) +→ BUILD_HA_CALLS (helpers advisor_* + divergence + compteurs + logbook) +→ Execute HA Service +``` + +### GARANTIE MODE OMBRE (structurelle, testée) +BUILD_HA_CALLS n'écrit QUE vers `input_text/input_number.evcs_advisor_*` et +logbook. Le test `tests/advisor/run.mjs` ÉCHOUE si une écriture sort de ce +périmètre (16 tests : kill-switch, réponses valides/hors-bornes/non-JSON/ +/offline, périmètre ombre, compteurs accord/divergence). +Suite intégrée au push : `n8n-push.mjs workflows/evcs-advisor-shadow-v1.json`. + +### Helpers HA (package NFS /mnt/ARCH3/docker/HA/packages/evcs_advisor_shadow.yaml) +`evcs_advisor_enabled` (kill-switch) · `advisor_strategy` · `advisor_night_target_soc` +(22-90) · `advisor_current_max_a` (6-28) · `advisor_ev_charge_tonight` · `advisor_reason` +· `advisor_status` (ok/invalid/llm_offline) · `advisor_divergence` · `advisor_last_run` +· `advisor_model` · `advisor_agree_count` / `advisor_diverge_count`. +Carte dashboard « 🤖 Conseiller IA (ombre) » ajoutée à evcs_master.yaml (backup dans SAVE/). + +### Première recommandation réelle (07/08 22:40, via qwen3-vl local) +`auto_solar_plus` | cible 35% | charge VE cette nuit: oui | confiance 90% | +« Prévision solaire forte (40.7 kWh), cible SOC matin basse adaptée. Véhicule +chargé en HC avec réseau EDF. » → **accord avec le réel** (et avec P7/P8 !). + +### Pièges rencontrés (pour mémoire) +1. Template /api/template DOIT être du Jinja : `{{ { ... } | tojson }}` — + sans wrapper, HA renvoie le texte brut non rendu. +2. Modèle local = qwen3-vl **Thinking** : max_tokens 2500 → JSON tronqué par le + raisonnement → rejeté proprement (`invalid: JSON non terminé`). Fix : 6000. + (La validation stricte a fait exactement son travail dès le 1er run réel.) +3. MCP activate_workflow → erreur 415 : activer via REST POST .../activate body {}. +4. n8n-push : le fallback d'id par défaut (EVCS) était DANGEREUX avec un 2e + workflow → supprimé, l'id vient du champ `id` du JSON ou de l'argument. + +### Outillage multi-workflows (extension R2) +`lib-node-map.mjs` : WORKFLOWS[] (evcs + advisor) · build/extract/push acceptent +un fichier cible · `n8n-create.mjs` (POST création) · `gen-advisor-workflow.mjs` +(squelette advisor rejouable, credentials/ids inclus). + +### Phase 3 — critères de bascule (dans 2-3 semaines d'observation) +1. Taux d'accord stratégie ≥ 80% (compteurs agree/diverge sur la carte) ; +2. Zéro sortie invalide NON rejetée (advisor_status) ; +3. Divergences analysées = soit l'agent a raison (améliorer les règles), + soit il a tort (améliorer le prompt) — les deux font progresser le système. +→ Alors premier câblage réel LIMITÉ : night_target_soc uniquement, derrière un + nouveau toggle `advisor_apply_night_target`, le reste inchangé. + diff --git a/src/advisor/BUILD_HA_CALLS.js b/src/advisor/BUILD_HA_CALLS.js new file mode 100644 index 0000000..c009d64 --- /dev/null +++ b/src/advisor/BUILD_HA_CALLS.js @@ -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; diff --git a/src/advisor/BUILD_PROMPT.js b/src/advisor/BUILD_PROMPT.js new file mode 100644 index 0000000..8a84dd2 --- /dev/null +++ b/src/advisor/BUILD_PROMPT.js @@ -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":,"current_max_a":,', + '"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, + } +}]; diff --git a/src/advisor/HA_SNAPSHOT_TEMPLATE.json.tpl b/src/advisor/HA_SNAPSHOT_TEMPLATE.json.tpl new file mode 100644 index 0000000..8367424 --- /dev/null +++ b/src/advisor/HA_SNAPSHOT_TEMPLATE.json.tpl @@ -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 }} diff --git a/src/advisor/VALIDATE_PARSE.js b/src/advisor/VALIDATE_PARSE.js new file mode 100644 index 0000000..bc04d7c --- /dev/null +++ b/src/advisor/VALIDATE_PARSE.js @@ -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 (...) 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, + } +}]; diff --git a/tests/advisor/run.mjs b/tests/advisor/run.mjs new file mode 100644 index 0000000..10830e9 --- /dev/null +++ b/tests/advisor/run.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +// Tests de l'EVCS Advisor Shadow V1 : exécute le VRAI code de BUILD_PROMPT et +// VALIDATE_PARSE (sandbox vm) contre des réponses LLM simulées. +// Vérifie aussi la GARANTIE MODE OMBRE : BUILD_HA_CALLS ne touche que advisor_*/logbook. +// Intégré au push (tools/n8n-push.mjs) pour le workflow advisor. + +import fs from 'fs'; +import vm from 'vm'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '../..'); +const src = f => fs.readFileSync(path.join(ROOT, 'src/advisor', f), 'utf8'); + +const BUILD_PROMPT = src('BUILD_PROMPT.js'); +const VALIDATE_PARSE = src('VALIDATE_PARSE.js'); +const BUILD_HA_CALLS = src('BUILD_HA_CALLS.js'); + +function run(code, inputJson, nodeOutputs = {}) { + const ctx = { + $input: { first: () => ({ json: inputJson }) }, + $: name => ({ first: () => ({ json: nodeOutputs[name] }) }), + console, Date, Intl, Math, Number, Object, String, JSON, Array, Boolean, + }; + vm.createContext(ctx); + return vm.runInContext(`(function(){\n${code}\n})()`, ctx); +} + +// Snapshot simulé (soir d'été, VW branchée, SOC 80) +const snapshot = { + data: JSON.stringify({ + advisor_enabled: 'on', advisor_agree_count: '10', advisor_diverge_count: '2', + advisor_prev_strategy: 'auto_solar_plus', + soc_percent: '80', battery_current_a: '-5', pv_power_w: '500', pv_power_10m_avg_w: '600', + house_ac_power_w: '900', grid_state: 'off', acin1_available: '0', + evcs_vehicle_connected: 'on', evcs_charging: 'off', evcs_current_a: '0', + evcs_surplus_state: 'idle_wait_conditions', evcs_source_mode: 'Solaire', + user_strategy: 'auto_solar_plus', allow_grid_hc_night: 'on', allow_grid_hc_midday: 'on', + night_target_soc_actual: '35', current_max_auto_a: '22', plafond_edf_on_a: '20', + departure_time: '08:50:00', departure_priority_enabled: 'on', required_energy_kwh: '10.5', + today_remaining_kwh: '1.2', tomorrow_kwh: '45', + }), +}; + +let failures = 0; +function check(label, cond, detail = '') { + if (cond) console.log(`✅ ${label}`); + else { failures++; console.log(`❌ ${label} ${detail}`); } +} + +// ---------- BUILD_PROMPT ---------- +const promptOut = run(BUILD_PROMPT, snapshot)[0].json; +check('BUILD_PROMPT: enabled=true', promptOut.enabled === true); +check('BUILD_PROMPT: 2 messages system+user', promptOut.messages?.length === 2 && promptOut.messages[0].role === 'system'); +check('BUILD_PROMPT: contexte transporté', promptOut.context?.soc === 80 && promptOut.context?.userStrategy === 'auto_solar_plus'); +check('BUILD_PROMPT: prompt < 6000 chars (modèle local)', (promptOut.messages[0].content.length + promptOut.messages[1].content.length) < 6000); + +const killSwitchOut = run(BUILD_PROMPT, { data: JSON.stringify({ ...JSON.parse(snapshot.data), advisor_enabled: 'off' }) })[0].json; +check('BUILD_PROMPT: kill-switch OFF → enabled=false', killSwitchOut.enabled === false); + +// ---------- VALIDATE_PARSE : 7 réponses LLM simulées ---------- +const ctxNode = { BUILD_PROMPT: promptOut }; +const validAdvice = '{"strategy":"auto_solar_plus","night_target_soc":35,"current_max_a":22,"ev_charge_tonight":"oui","confidence":0.85,"reason":"Forecast fort demain, HC nuit pour la VW."}'; + +const cases = [ + { + label: 'réponse valide → ok', + resp: { model: 'qwen3.6-35b', choices: [{ message: { content: validAdvice } }] }, + expect: o => o.status === 'ok' && o.advice.strategy === 'auto_solar_plus' && o.advice.nightTargetSoc === 35, + }, + { + label: 'balises + fences markdown → ok', + resp: { model: 'qwen3-vl', choices: [{ message: { content: `je réfléchis...\nblabla\n\`\`\`json\n${validAdvice}\n\`\`\`` } }] }, + expect: o => o.status === 'ok' && o.advice.evChargeTonight === 'oui', + }, + { + label: 'stratégie hors enum → invalid (rejet)', + resp: { model: 'x', choices: [{ message: { content: validAdvice.replace('auto_solar_plus', 'charge_maximale') } }] }, + expect: o => o.status === 'invalid' && o.advice === null, + }, + { + label: 'night_target_soc 15 (< plancher 22) → invalid', + resp: { model: 'x', choices: [{ message: { content: validAdvice.replace('"night_target_soc":35', '"night_target_soc":15') } }] }, + expect: o => o.status === 'invalid' && o.advice === null, + }, + { + label: 'current_max_a 40 (> 28) → invalid', + resp: { model: 'x', choices: [{ message: { content: validAdvice.replace('"current_max_a":22', '"current_max_a":40') } }] }, + expect: o => o.status === 'invalid' && o.advice === null, + }, + { + label: 'texte sans JSON → invalid', + resp: { model: 'x', choices: [{ message: { content: 'Je recommande le mode solaire.' } }] }, + expect: o => o.status === 'invalid', + }, + { + label: 'les deux LLM en échec → llm_offline', + resp: { error: 'connect ECONNREFUSED 10.10.10.8:8000' }, + expect: o => o.status === 'llm_offline' && o.advice === null, + }, +]; + +for (const c of cases) { + const out = run(VALIDATE_PARSE, c.resp, ctxNode)[0].json; + check(`VALIDATE: ${c.label}`, c.expect(out), `(obtenu: ${JSON.stringify(out).slice(0, 120)})`); +} + +// ---------- BUILD_HA_CALLS : garantie mode ombre ---------- +const okValidated = run(VALIDATE_PARSE, cases[0].resp, ctxNode)[0].json; +const calls = run(BUILD_HA_CALLS, okValidated, ctxNode).map(i => i.json); +const ALLOWED = /^(input_text|input_number)\.evcs_advisor_/; +const violations = calls.filter(c => !(c.domain === 'logbook' || ALLOWED.test(c.data.entity_id || ''))); +check('OMBRE: aucune écriture hors advisor_*/logbook', violations.length === 0, JSON.stringify(violations)); +check('OMBRE: écrit strategy + reason + divergence + compteur', calls.some(c => (c.data.entity_id || '').includes('advisor_strategy')) && calls.some(c => (c.data.entity_id || '').includes('advisor_divergence')) && calls.some(c => (c.data.entity_id || '').includes('agree_count'))); +check('OMBRE: accord détecté (agent=réel=auto_solar_plus)', calls.some(c => (c.data.entity_id || '').includes('agree_count')) && !calls.some(c => (c.data.entity_id || '').includes('diverge_count'))); + +const offlineValidated = run(VALIDATE_PARSE, cases[6].resp, ctxNode)[0].json; +const offlineCalls = run(BUILD_HA_CALLS, offlineValidated, ctxNode).map(i => i.json); +check('OMBRE: llm_offline → statut écrit, pas de recommandation', offlineCalls.every(c => !(c.data.entity_id || '').includes('advisor_strategy')) && offlineCalls.some(c => (c.data.value || '').toString().startsWith('llm_offline'))); + +console.log(failures ? `\n${failures} ÉCHEC(S) — NE PAS POUSSER` : '\nADVISOR OK — tous les tests passent'); +process.exit(failures ? 1 : 0); diff --git a/tools/gen-advisor-workflow.mjs b/tools/gen-advisor-workflow.mjs new file mode 100644 index 0000000..ba73e83 --- /dev/null +++ b/tools/gen-advisor-workflow.mjs @@ -0,0 +1,214 @@ +#!/usr/bin/env node +// Génère workflows/evcs-advisor-shadow-v1.json (squelette : nodes, connexions, +// credentials, positions). Le CODE des nodes est ensuite injecté depuis +// src/advisor/ par tools/n8n-build.mjs (source de vérité R2). +// Rejouable : écrase le fichier local (pas le serveur). + +import fs from 'fs'; + +const HA_CRED = { id: 'uv3sknYYigxBkaVN', name: 'Bearer Auth account 2' }; +const OPENROUTER_CRED = { id: 'eOM9xofDHT0KMoeY', name: 'OpenRouter Advisor' }; +const SPARK_URL = 'http://10.10.10.8:8000'; +const CLOUD_MODEL = 'anthropic/claude-haiku-4.5'; +const HA_URL = 'http://10.10.10.17:8123'; + +const nodes = [ + { + name: 'Schedule Trigger', + type: 'n8n-nodes-base.scheduleTrigger', + typeVersion: 1.2, + position: [0, 0], + parameters: { rule: { interval: [{ field: 'minutes', minutesInterval: 15 }] } }, + }, + { + name: 'HA Snapshot Advisor', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.2, + position: [200, 0], + parameters: { + method: 'POST', + url: `${HA_URL}/api/template`, + authentication: 'genericCredentialType', + genericAuthType: 'httpBearerAuth', + sendHeaders: true, + headerParameters: { + parameters: [ + { name: 'Content-Type', value: 'application/json' }, + { name: 'Accept', value: 'text/plain' }, + ], + }, + sendBody: true, + bodyParameters: { parameters: [{ name: 'template', value: '(injecté depuis src/advisor)' }] }, + options: { timeout: 15000 }, + }, + credentials: { httpBearerAuth: HA_CRED }, + }, + { + name: 'BUILD_PROMPT', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [400, 0], + parameters: { jsCode: '// injecté depuis src/advisor/BUILD_PROMPT.js' }, + }, + { + name: 'IF_ENABLED', + type: 'n8n-nodes-base.if', + typeVersion: 2.2, + position: [600, 0], + parameters: { + conditions: { + options: { caseSensitive: true, leftValue: '', typeValidation: 'loose', version: 2 }, + combinator: 'and', + conditions: [ + { + leftValue: '={{ $json.enabled }}', + rightValue: '', + operator: { type: 'boolean', operation: 'true', singleValue: true }, + }, + ], + }, + }, + }, + { + name: 'LLM_LOCAL_MODELS', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.2, + position: [800, -100], + parameters: { + method: 'GET', + url: `${SPARK_URL}/v1/models`, + options: { timeout: 5000 }, + }, + onError: 'continueRegularOutput', + alwaysOutputData: true, + }, + { + name: 'LLM_LOCAL', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.2, + position: [1000, -100], + parameters: { + method: 'POST', + url: `${SPARK_URL}/v1/chat/completions`, + sendHeaders: true, + headerParameters: { parameters: [{ name: 'Content-Type', value: 'application/json' }] }, + sendBody: true, + specifyBody: 'json', + jsonBody: + "={{ JSON.stringify({ model: $('LLM_LOCAL_MODELS').first().json.data[0].id, messages: $('BUILD_PROMPT').first().json.messages, temperature: 0.1, max_tokens: 6000 }) }}", + options: { timeout: 120000 }, + }, + onError: 'continueRegularOutput', + alwaysOutputData: true, + }, + { + name: 'IF_LOCAL_OK', + type: 'n8n-nodes-base.if', + typeVersion: 2.2, + position: [1200, -100], + parameters: { + conditions: { + options: { caseSensitive: true, leftValue: '', typeValidation: 'loose', version: 2 }, + combinator: 'and', + conditions: [ + { + leftValue: "={{ $json.choices !== undefined && $json.choices.length > 0 }}", + rightValue: '', + operator: { type: 'boolean', operation: 'true', singleValue: true }, + }, + ], + }, + }, + }, + { + name: 'LLM_CLOUD', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.2, + position: [1400, 40], + parameters: { + method: 'POST', + url: 'https://openrouter.ai/api/v1/chat/completions', + authentication: 'genericCredentialType', + genericAuthType: 'httpHeaderAuth', + sendHeaders: true, + headerParameters: { parameters: [{ name: 'Content-Type', value: 'application/json' }] }, + sendBody: true, + specifyBody: 'json', + jsonBody: + `={{ JSON.stringify({ model: '${CLOUD_MODEL}', messages: $('BUILD_PROMPT').first().json.messages, temperature: 0.1, max_tokens: 1000 }) }}`, + options: { timeout: 60000 }, + }, + credentials: { httpHeaderAuth: OPENROUTER_CRED }, + onError: 'continueRegularOutput', + alwaysOutputData: true, + }, + { + name: 'VALIDATE_PARSE', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [1600, -30], + parameters: { jsCode: '// injecté depuis src/advisor/VALIDATE_PARSE.js' }, + }, + { + name: 'BUILD_HA_CALLS', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [1800, -30], + parameters: { jsCode: '// injecté depuis src/advisor/BUILD_HA_CALLS.js' }, + }, + { + name: 'Execute HA Service Advisor', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.2, + position: [2000, -30], + parameters: { + method: 'POST', + url: "={{ 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}", + authentication: 'genericCredentialType', + genericAuthType: 'httpBearerAuth', + sendHeaders: true, + headerParameters: { + parameters: [ + { name: 'Content-Type', value: 'application/json' }, + { name: 'accept', value: 'application/json' }, + ], + }, + sendBody: true, + specifyBody: 'json', + jsonBody: '={{ $json.data }}', + options: { timeout: 15000 }, + }, + credentials: { httpBearerAuth: HA_CRED }, + onError: 'continueRegularOutput', + }, +]; + +const connections = { + 'Schedule Trigger': { main: [[{ node: 'HA Snapshot Advisor', type: 'main', index: 0 }]] }, + 'HA Snapshot Advisor': { main: [[{ node: 'BUILD_PROMPT', type: 'main', index: 0 }]] }, + BUILD_PROMPT: { main: [[{ node: 'IF_ENABLED', type: 'main', index: 0 }]] }, + IF_ENABLED: { main: [[{ node: 'LLM_LOCAL_MODELS', type: 'main', index: 0 }], []] }, + LLM_LOCAL_MODELS: { main: [[{ node: 'LLM_LOCAL', type: 'main', index: 0 }]] }, + LLM_LOCAL: { main: [[{ node: 'IF_LOCAL_OK', type: 'main', index: 0 }]] }, + IF_LOCAL_OK: { + main: [ + [{ node: 'VALIDATE_PARSE', type: 'main', index: 0 }], + [{ node: 'LLM_CLOUD', type: 'main', index: 0 }], + ], + }, + LLM_CLOUD: { main: [[{ node: 'VALIDATE_PARSE', type: 'main', index: 0 }]] }, + VALIDATE_PARSE: { main: [[{ node: 'BUILD_HA_CALLS', type: 'main', index: 0 }]] }, + BUILD_HA_CALLS: { main: [[{ node: 'Execute HA Service Advisor', type: 'main', index: 0 }]] }, +}; + +const workflow = { + name: 'EVCS Advisor Shadow V1', + nodes, + connections, + settings: { executionOrder: 'v1', callerPolicy: 'workflowsFromSameOwner' }, + staticData: null, +}; + +fs.writeFileSync('workflows/evcs-advisor-shadow-v1.json', JSON.stringify(workflow, null, 2) + '\n'); +console.log(`Squelette écrit: workflows/evcs-advisor-shadow-v1.json (${nodes.length} nodes)`); +console.log('→ lancer: node tools/n8n-build.mjs workflows/evcs-advisor-shadow-v1.json'); diff --git a/tools/lib-node-map.mjs b/tools/lib-node-map.mjs index 9e9ae52..c057367 100644 --- a/tools/lib-node-map.mjs +++ b/tools/lib-node-map.mjs @@ -1,19 +1,40 @@ -// R2 — Cartographie src/nodes/ ↔ workflow JSON (source de vérité : src/nodes/). +// R2 — Cartographie src/ ↔ workflows JSON (source de vérité : src/). +// Multi-workflows depuis l'Advisor (08/2026). // Utilisé par tools/n8n-extract.mjs (JSON → src) et tools/n8n-build.mjs (src → JSON). -export const WORKFLOW_FILE = 'workflows/evcs-surplus-controller-v2.6.json'; -export const SRC_DIR = 'src/nodes'; - -export const NODE_MAP = [ - { file: 'INPUT_PARSE.js', node: 'INPUT_PARSE', kind: 'jsCode' }, - { file: 'WINDOWS_SOURCEV2.js', node: 'WINDOWS_SOURCEV2', kind: 'jsCode' }, - { file: 'BATTERY_CLOUD_LOCKS.js', node: 'BATTERY_CLOUD_LOCKS', kind: 'jsCode' }, - { file: 'DECISION_ENGINEV2.js', node: 'DECISION_ENGINEV2', kind: 'jsCode' }, - { file: 'HA_SERVICE_CALLS_OUTPUT.js', node: 'HA_SERVICE_CALLS_OUTPUT', kind: 'jsCode' }, - { file: 'BUILD_ACTIONS.js', node: 'Build EVCS Surplus Controller V2.0 Actions', kind: 'jsCode' }, - { file: 'HA_SNAPSHOT_TEMPLATE.json.tpl', node: 'HA Snapshot EVCS Surplus Controller V2.2', kind: 'template' }, +export const WORKFLOWS = [ + { + id: 'evcs', + workflowFile: 'workflows/evcs-surplus-controller-v2.6.json', + srcDir: 'src/nodes', + nodes: [ + { file: 'INPUT_PARSE.js', node: 'INPUT_PARSE', kind: 'jsCode' }, + { file: 'WINDOWS_SOURCEV2.js', node: 'WINDOWS_SOURCEV2', kind: 'jsCode' }, + { file: 'BATTERY_CLOUD_LOCKS.js', node: 'BATTERY_CLOUD_LOCKS', kind: 'jsCode' }, + { file: 'DECISION_ENGINEV2.js', node: 'DECISION_ENGINEV2', kind: 'jsCode' }, + { file: 'HA_SERVICE_CALLS_OUTPUT.js', node: 'HA_SERVICE_CALLS_OUTPUT', kind: 'jsCode' }, + { file: 'BUILD_ACTIONS.js', node: 'Build EVCS Surplus Controller V2.0 Actions', kind: 'jsCode' }, + { file: 'HA_SNAPSHOT_TEMPLATE.json.tpl', node: 'HA Snapshot EVCS Surplus Controller V2.2', kind: 'template' }, + ], + }, + { + id: 'advisor', + workflowFile: 'workflows/evcs-advisor-shadow-v1.json', + srcDir: 'src/advisor', + nodes: [ + { file: 'BUILD_PROMPT.js', node: 'BUILD_PROMPT', kind: 'jsCode' }, + { file: 'VALIDATE_PARSE.js', node: 'VALIDATE_PARSE', kind: 'jsCode' }, + { file: 'BUILD_HA_CALLS.js', node: 'BUILD_HA_CALLS', kind: 'jsCode' }, + { file: 'HA_SNAPSHOT_TEMPLATE.json.tpl', node: 'HA Snapshot Advisor', kind: 'template' }, + ], + }, ]; +// Rétro-compatibilité (outils historiques mono-workflow) +export const WORKFLOW_FILE = WORKFLOWS[0].workflowFile; +export const SRC_DIR = WORKFLOWS[0].srcDir; +export const NODE_MAP = WORKFLOWS[0].nodes; + export function getCode(workflowNode, kind) { if (kind === 'jsCode') return workflowNode.parameters.jsCode; if (kind === 'template') { @@ -30,3 +51,8 @@ export function setCode(workflowNode, kind, code) { } throw new Error(`kind inconnu: ${kind}`); } + +export function findWorkflowByFile(inputPath) { + const base = inputPath.replace(/\\/g, '/').split('/').pop(); + return WORKFLOWS.find(w => w.workflowFile.endsWith(base)); +} diff --git a/tools/n8n-build.mjs b/tools/n8n-build.mjs index cac3c25..9cb9d9b 100644 --- a/tools/n8n-build.mjs +++ b/tools/n8n-build.mjs @@ -1,38 +1,46 @@ #!/usr/bin/env node -// R2 — BUILD : src/nodes/*.js → workflow JSON (src/ = SOURCE DE VÉRITÉ, src gagne). -// Usage : node tools/n8n-build.mjs [--check] -// --check : vérifie que le JSON est déjà synchronisé avec src/ (exit 1 sinon), -// sans rien écrire. +// R2 — BUILD : src/ → workflows JSON (src = SOURCE DE VÉRITÉ, src gagne). +// Usage : node tools/n8n-build.mjs [--check] [fichierWorkflow] +// --check : vérifie la synchro sans écrire (exit 1 si désynchronisé) +// fichierWorkflow : limite à un workflow (sinon tous ceux du node-map) import fs from 'fs'; import path from 'path'; -import { WORKFLOW_FILE, SRC_DIR, NODE_MAP, getCode, setCode } from './lib-node-map.mjs'; +import { WORKFLOWS, getCode, setCode, findWorkflowByFile } from './lib-node-map.mjs'; const CHECK = process.argv.includes('--check'); -const wf = JSON.parse(fs.readFileSync(WORKFLOW_FILE, 'utf8')); +const fileArg = process.argv.slice(2).find(a => !a.startsWith('--')); +const targets = fileArg ? [findWorkflowByFile(fileArg)].filter(Boolean) : WORKFLOWS; +if (fileArg && !targets.length) { + console.log(`(${fileArg} non enregistré dans le node-map : rien à builder)`); + process.exit(0); +} -let changes = 0; -for (const { file, node, kind } of NODE_MAP) { - const srcPath = path.join(SRC_DIR, file); - if (!fs.existsSync(srcPath)) throw new Error(`Fichier source manquant: ${srcPath}`); - const srcCode = fs.readFileSync(srcPath, 'utf8'); - const n = wf.nodes.find(x => x.name === node); - if (!n) throw new Error(`Node introuvable dans le JSON: ${node}`); - if (getCode(n, kind) !== srcCode) { - changes++; - console.log(`${CHECK ? 'DÉSYNCHRONISÉ' : 'inject'}: ${file} → "${node}"`); - if (!CHECK) setCode(n, kind, srcCode); +let totalChanges = 0; +for (const w of targets) { + if (!fs.existsSync(w.workflowFile)) { + console.log(`(skip ${w.id}: ${w.workflowFile} absent)`); + continue; } + const wf = JSON.parse(fs.readFileSync(w.workflowFile, 'utf8')); + let changes = 0; + for (const { file, node, kind } of w.nodes) { + const srcPath = path.join(w.srcDir, file); + if (!fs.existsSync(srcPath)) throw new Error(`Fichier source manquant: ${srcPath}`); + const srcCode = fs.readFileSync(srcPath, 'utf8'); + const n = wf.nodes.find(x => x.name === node); + if (!n) throw new Error(`Node introuvable dans ${w.workflowFile}: ${node}`); + if (getCode(n, kind) !== srcCode) { + changes++; + console.log(`${CHECK ? 'DÉSYNCHRONISÉ' : 'inject'}: ${w.srcDir}/${file} → "${node}"`); + if (!CHECK) setCode(n, kind, srcCode); + } + } + if (!CHECK && changes) { + fs.writeFileSync(w.workflowFile, JSON.stringify(wf, null, 2) + '\n'); + } + totalChanges += changes; + console.log(`${w.id}: ${changes ? changes + ' node(s) ' + (CHECK ? 'désynchronisé(s)' : 'injecté(s)') : 'synchronisé'}`); } -if (CHECK) { - console.log(changes ? `\n${changes} node(s) désynchronisé(s) : lancer node tools/n8n-build.mjs` : 'JSON synchronisé avec src/nodes/'); - process.exit(changes ? 1 : 0); -} - -if (changes) { - fs.writeFileSync(WORKFLOW_FILE, JSON.stringify(wf, null, 2) + '\n'); - console.log(`\nBuild OK: ${changes} node(s) injecté(s) dans ${WORKFLOW_FILE}`); -} else { - console.log('Build: aucun changement (déjà synchronisé)'); -} +if (CHECK) process.exit(totalChanges ? 1 : 0); diff --git a/tools/n8n-create.mjs b/tools/n8n-create.mjs new file mode 100644 index 0000000..1182f65 --- /dev/null +++ b/tools/n8n-create.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +// Crée un workflow n8n depuis un fichier JSON local (POST /api/v1/workflows). +// Usage: node tools/n8n-create.mjs + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const INPUT = process.argv[2]; +if (!INPUT) { console.error('Usage: node tools/n8n-create.mjs '); process.exit(1); } +const local = JSON.parse(fs.readFileSync(INPUT, 'utf8')); + +const cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.config/opencode/opencode.json'), 'utf8')); +const env = cfg.mcp['n8n-mcp'].environment; +const url = env.N8N_API_URL.replace(/\/api\/v1\/?$/, ''); +const headers = { 'X-N8N-API-KEY': env.N8N_API_KEY, 'Content-Type': 'application/json' }; + +const body = { + name: local.name, + nodes: local.nodes, + connections: local.connections, + settings: { + executionOrder: local.settings?.executionOrder || 'v1', + callerPolicy: local.settings?.callerPolicy || 'workflowsFromSameOwner', + }, +}; + +const res = await fetch(`${url}/api/v1/workflows`, { method: 'POST', headers, body: JSON.stringify(body) }); +const text = await res.text(); +if (!res.ok) { + console.error(`CRÉATION REFUSÉE: ${res.status} ${res.statusText}`); + console.error(text.slice(0, 1000)); + process.exit(1); +} +const created = JSON.parse(text); +console.log(`Créé: "${created.name}" id=${created.id} (active=${created.active})`); +console.log(`→ reporter l'id dans le fichier local si besoin: ${INPUT}`); diff --git a/tools/n8n-extract.mjs b/tools/n8n-extract.mjs index 4a03827..c89ad0a 100644 --- a/tools/n8n-extract.mjs +++ b/tools/n8n-extract.mjs @@ -1,21 +1,27 @@ #!/usr/bin/env node -// R2 — EXTRACT : workflow JSON → src/nodes/*.js (+ template snapshot). -// À utiliser UNIQUEMENT pour (re)synchroniser src/ après une modification faite -// directement dans l'UI n8n (à éviter). En temps normal : éditer src/nodes/, -// puis tools/n8n-push.mjs reconstruit le JSON automatiquement. +// R2 — EXTRACT : workflows JSON → src/ (resynchronisation après édition UI n8n — à éviter). +// Usage : node tools/n8n-extract.mjs [fichierWorkflow] import fs from 'fs'; import path from 'path'; -import { WORKFLOW_FILE, SRC_DIR, NODE_MAP, getCode } from './lib-node-map.mjs'; +import { WORKFLOWS, getCode, findWorkflowByFile } from './lib-node-map.mjs'; -const wf = JSON.parse(fs.readFileSync(WORKFLOW_FILE, 'utf8')); -fs.mkdirSync(SRC_DIR, { recursive: true }); +const fileArg = process.argv.slice(2).find(a => !a.startsWith('--')); +const targets = fileArg ? [findWorkflowByFile(fileArg)].filter(Boolean) : WORKFLOWS; -for (const { file, node, kind } of NODE_MAP) { - const n = wf.nodes.find(x => x.name === node); - if (!n) throw new Error(`Node introuvable: ${node}`); - const code = getCode(n, kind); - fs.writeFileSync(path.join(SRC_DIR, file), code); - console.log(`${file} ← "${node}" (${code.length} chars)`); +for (const w of targets) { + if (!fs.existsSync(w.workflowFile)) { + console.log(`(skip ${w.id}: ${w.workflowFile} absent)`); + continue; + } + const wf = JSON.parse(fs.readFileSync(w.workflowFile, 'utf8')); + fs.mkdirSync(w.srcDir, { recursive: true }); + for (const { file, node, kind } of w.nodes) { + const n = wf.nodes.find(x => x.name === node); + if (!n) throw new Error(`Node introuvable: ${node} (${w.workflowFile})`); + const code = getCode(n, kind); + fs.writeFileSync(path.join(w.srcDir, file), code); + console.log(`${w.srcDir}/${file} ← "${node}" (${code.length} chars)`); + } } -console.log(`\nExtraction OK dans ${SRC_DIR}/`); +console.log('\nExtraction OK'); diff --git a/tools/n8n-push.mjs b/tools/n8n-push.mjs index cd8b20c..55a9eb4 100644 --- a/tools/n8n-push.mjs +++ b/tools/n8n-push.mjs @@ -15,14 +15,14 @@ if (!INPUT) { process.exit(1); } -// R2 : src/nodes/ = SOURCE DE VÉRITÉ pour le workflow EVCS. -// Le build injecte src/nodes/*.js dans le JSON AVANT toute vérification : -// toute édition faite directement dans le JSON (ou l'UI n8n non ré-extraite) -// est écrasée par src/. Workflow EVCS uniquement. -if (path.basename(INPUT) === 'evcs-surplus-controller-v2.6.json' && fs.existsSync('src/nodes')) { - console.log('Build src/nodes/ → JSON (source de vérité : src/)...'); +// R2 : src/ = SOURCE DE VÉRITÉ pour les workflows enregistrés dans le node-map. +// Le build injecte src/**/*.js dans le JSON AVANT toute vérification. +const { findWorkflowByFile } = await import('./lib-node-map.mjs'); +const registered = findWorkflowByFile(INPUT); +if (registered) { + console.log(`Build ${registered.srcDir}/ → JSON (source de vérité : src/)...`); const { spawnSync } = await import('child_process'); - const b = spawnSync('node', ['tools/n8n-build.mjs'], { stdio: 'inherit' }); + const b = spawnSync('node', ['tools/n8n-build.mjs', INPUT], { stdio: 'inherit' }); if (b.status !== 0) { console.error('ABANDON: échec du build src → JSON.'); process.exit(1); @@ -30,7 +30,11 @@ if (path.basename(INPUT) === 'evcs-surplus-controller-v2.6.json' && fs.existsSyn } const local = JSON.parse(fs.readFileSync(INPUT, 'utf8')); -const WORKFLOW_ID = process.argv[3] || local.id || 'ATswvGfG4KJUls0K'; +const WORKFLOW_ID = process.argv[3] || local.id; +if (!WORKFLOW_ID) { + console.error('ABANDON: aucun id de workflow (ni argument, ni champ id dans le JSON).'); + process.exit(1); +} function loadApiConfig() { if (process.env.N8N_API_KEY) { @@ -101,20 +105,25 @@ if (syntaxErrors) { } console.log('Syntaxe OK sur tous les nodes jsCode.'); -// 3bis. Suite golden (R3) : obligatoire pour le workflow EVCS, sauf --skip-golden +// 3bis. Suites de tests (R3) : bloquantes par workflow, sauf --skip-golden const isEvcsWorkflow = path.basename(INPUT) === 'evcs-surplus-controller-v2.6.json'; +const isAdvisorWorkflow = path.basename(INPUT) === 'evcs-advisor-shadow-v1.json'; const skipGolden = process.argv.includes('--skip-golden'); -if (isEvcsWorkflow && fs.existsSync('tests/golden/run.mjs')) { +const testSuite = + isEvcsWorkflow && fs.existsSync('tests/golden/run.mjs') ? 'tests/golden/run.mjs' : + isAdvisorWorkflow && fs.existsSync('tests/advisor/run.mjs') ? 'tests/advisor/run.mjs' : + null; +if (testSuite) { if (skipGolden) { - console.log('⚠️ Suite golden SAUTÉE (--skip-golden) — à n\'utiliser qu\'en connaissance de cause.'); + console.log('⚠️ Suite de tests SAUTÉE (--skip-golden) — à n\'utiliser qu\'en connaissance de cause.'); } else { - console.log('Exécution de la suite golden (tests/golden/run.mjs)...'); + console.log(`Exécution de la suite de tests (${testSuite})...`); const { spawnSync } = await import('child_process'); - const g = spawnSync('node', ['tests/golden/run.mjs'], { stdio: 'inherit' }); + const g = spawnSync('node', [testSuite], { stdio: 'inherit' }); if (g.status !== 0) { - console.error('ABANDON: la suite golden échoue. Rien n\'a été poussé.'); + console.error('ABANDON: la suite de tests échoue. Rien n\'a été poussé.'); console.error('(Évolution volontaire du comportement ? Mettre à jour les attendus'); - console.error(' dans tests/golden/scenarios.mjs dans le même commit, puis re-pousser.)'); + console.error(' de la suite dans le même commit, puis re-pousser.)'); process.exit(1); } } diff --git a/workflows/evcs-advisor-shadow-v1.json b/workflows/evcs-advisor-shadow-v1.json new file mode 100644 index 0000000..7bdae19 --- /dev/null +++ b/workflows/evcs-advisor-shadow-v1.json @@ -0,0 +1,421 @@ +{ + "name": "EVCS Advisor Shadow V1", + "nodes": [ + { + "name": "Schedule Trigger", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [ + 0, + 0 + ], + "parameters": { + "rule": { + "interval": [ + { + "field": "minutes", + "minutesInterval": 15 + } + ] + } + } + }, + { + "name": "HA Snapshot Advisor", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 200, + 0 + ], + "parameters": { + "method": "POST", + "url": "http://10.10.10.17:8123/api/template", + "authentication": "genericCredentialType", + "genericAuthType": "httpBearerAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "Accept", + "value": "text/plain" + } + ] + }, + "sendBody": true, + "bodyParameters": { + "parameters": [ + { + "name": "template", + "value": "{{ {\n \"advisor_enabled\": states(\"input_boolean.evcs_advisor_enabled\"),\n \"advisor_agree_count\": states(\"input_number.evcs_advisor_agree_count\"),\n \"advisor_diverge_count\": states(\"input_number.evcs_advisor_diverge_count\"),\n \"advisor_prev_strategy\": states(\"input_text.evcs_advisor_strategy\"),\n\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n \"pv_power_10m_avg_w\": states(\"sensor.victron_pv_power_10m_avg\"),\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"grid_state\": states(\"switch.edf_general_ok_switch\"),\n \"acin1_available\": states(\"sensor.victron_ac_in1_available\"),\n\n \"evcs_vehicle_connected\": states(\"binary_sensor.victron_evcs_vehicle_connected\"),\n \"evcs_charging\": states(\"switch.victron_evcs_charging\"),\n \"evcs_current_a\": states(\"sensor.victron_evcs_current\"),\n \"evcs_surplus_state\": states(\"input_text.victron_evcs_surplus_state\"),\n \"evcs_source_mode\": states(\"input_text.victron_evcs_source_mode\"),\n\n \"user_strategy\": states(\"input_select.victron_evcs_strategy\"),\n \"allow_grid_hc_night\": states(\"input_boolean.victron_evcs_allow_grid_hc_night\"),\n \"allow_grid_hc_midday\": states(\"input_boolean.victron_evcs_allow_grid_hc_midday\"),\n \"night_target_soc_actual\": states(\"input_number.victron_evcs_night_target_soc\"),\n \"current_max_auto_a\": states(\"input_number.victron_evcs_current_max_auto_a\"),\n \"plafond_edf_on_a\": states(\"input_number.evcs_plafond_courant_edf_on\"),\n\n \"departure_time\": states(\"input_datetime.victron_evcs_departure_time\"),\n \"departure_priority_enabled\": states(\"input_boolean.victron_evcs_departure_priority_enabled\"),\n \"required_energy_kwh\": states(\"input_number.victron_evcs_required_energy_kwh\"),\n\n \"today_remaining_kwh\": states(\"sensor.energy_production_today_remaining\"),\n \"tomorrow_kwh\": states(\"sensor.energy_production_tomorrow\")\n} | tojson }}\n" + } + ] + }, + "options": { + "timeout": 15000 + } + }, + "credentials": { + "httpBearerAuth": { + "id": "uv3sknYYigxBkaVN", + "name": "Bearer Auth account 2" + } + } + }, + { + "name": "BUILD_PROMPT", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 400, + 0 + ], + "parameters": { + "jsCode": "// =====================================================\n// EVCS ADVISOR SHADOW V1 — BUILD_PROMPT\n// Construit le prompt LLM à partir du snapshot HA compact.\n// MODE OMBRE : ce workflow n'écrit que des helpers advisor_*.\n// =====================================================\n\nfunction num(v, d) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : d;\n}\nfunction onOff(v) {\n return (v ?? 'off').toString().trim().toLowerCase() === 'on';\n}\n\nconst raw = $input.first().json;\nconst input = typeof raw.data === 'string' ? JSON.parse(raw.data) : (raw.data || raw);\n\nconst ctx = {\n advisorEnabled: onOff(input.advisor_enabled),\n agreeCount: num(input.advisor_agree_count, 0),\n divergeCount: num(input.advisor_diverge_count, 0),\n prevAdvisorStrategy: (input.advisor_prev_strategy || 'unknown').toString(),\n\n soc: num(input.soc_percent, NaN),\n batteryCurrentA: num(input.battery_current_a, NaN),\n pvW: num(input.pv_power_w, 0),\n pvAvg10mW: num(input.pv_power_10m_avg_w, 0),\n houseW: num(input.house_ac_power_w, 0),\n gridOn: onOff(input.grid_state),\n acinAvailable: num(input.acin1_available, 0) === 1,\n\n vehicleConnected: onOff(input.evcs_vehicle_connected),\n evcsCharging: onOff(input.evcs_charging),\n evcsCurrentA: num(input.evcs_current_a, 0),\n surplusState: (input.evcs_surplus_state || '').toString(),\n sourceMode: (input.evcs_source_mode || '').toString(),\n\n userStrategy: (input.user_strategy || 'auto_solar_plus').toString().trim().toLowerCase(),\n allowGridHcNight: onOff(input.allow_grid_hc_night),\n allowGridHcMidday: onOff(input.allow_grid_hc_midday),\n nightTargetActual: num(input.night_target_soc_actual, 35),\n currentMaxAutoA: num(input.current_max_auto_a, 22),\n plafondEdfOnA: num(input.plafond_edf_on_a, 20),\n\n departureTime: (input.departure_time || '').toString(),\n departurePriority: onOff(input.departure_priority_enabled),\n requiredEnergyKwh: num(input.required_energy_kwh, 0),\n\n todayRemainingKwh: num(input.today_remaining_kwh, 0),\n tomorrowKwh: num(input.tomorrow_kwh, 0),\n};\n\n// Heure Paris\nconst parisFmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n weekday: 'long', day: '2-digit', month: '2-digit',\n hour: '2-digit', minute: '2-digit', hour12: false,\n});\nconst nowParis = parisFmt.format(new Date());\n\nconst systemPrompt = [\n 'Tu es le PLANIFICATEUR ÉNERGIE d\\'une maison autonome française. Tu es en MODE OBSERVATION :',\n 'tes recommandations sont enregistrées pour comparaison, elles ne pilotent rien.',\n '',\n 'INSTALLATION : Victron ESS SANS injection réseau. Batterie 17,5 kWh utile (plancher sécurité 22%).',\n 'PV ~9 kWc. Borne EVCS 6-28 A. Réseau EDF commutable (cher en HP, bon marché en HC).',\n '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).',\n '',\n 'STRATÉGIES POSSIBLES (input_select) :',\n '- auto_solar_plus : automatique, solaire prioritaire + réseau HC la nuit si véhicule branché (défaut recommandé)',\n '- auto_solar : automatique, solaire strict (jamais de réseau)',\n '- force_grid_now : charge VE immédiate sur réseau EDF (à réserver aux urgences/gros besoins)',\n '- force_charge_now : charge locale immédiate quelle que soit la source (secours manuel)',\n '- stop : tout couper',\n '',\n 'PRINCIPES : maximiser l\\'autoconsommation solaire ; réseau seulement en HC ou urgence ;',\n 'ne jamais viser un SOC matin < 22% ; forecast demain fort (>=18 kWh) => cible matin basse (~35%),',\n 'moyen (>=10) => ~55%, faible => ~85%. Plafond courant : 20 A max si EDF ON, sinon 22 A auto.',\n '',\n 'Réponds UNIQUEMENT avec un objet JSON valide, sans texte autour, au format EXACT :',\n '{\"strategy\":\"auto_solar_plus|auto_solar|force_grid_now|force_charge_now|stop\",',\n '\"night_target_soc\":,\"current_max_a\":,',\n '\"ev_charge_tonight\":\"oui|non|sans_objet\",\"confidence\":<0.0-1.0>,',\n '\"reason\":\"une phrase courte en français (max 200 caractères)\"}',\n].join('\\n');\n\nconst userPrompt = [\n `Nous sommes ${nowParis} (heure de Paris).`,\n '',\n 'ÉTAT ACTUEL :',\n `- Batterie : SOC ${ctx.soc}%, courant ${ctx.batteryCurrentA} A (positif = charge)`,\n `- PV : ${Math.round(ctx.pvW)} W instantané, ${Math.round(ctx.pvAvg10mW)} W moyenne 10 min`,\n `- Maison : ${Math.round(ctx.houseW)} W | Réseau EDF : ${ctx.gridOn ? 'ON' : 'OFF'} (AC-IN ${ctx.acinAvailable ? 'présent' : 'absent'})`,\n `- Véhicule : ${ctx.vehicleConnected ? 'BRANCHÉ' : 'non branché'}${ctx.evcsCharging ? `, en charge à ${ctx.evcsCurrentA} A` : ''}`,\n `- État contrôleur : ${ctx.surplusState} | Source : ${ctx.sourceMode}`,\n `- Stratégie réelle actuelle : ${ctx.userStrategy}`,\n `- Besoin véhicule : ${ctx.requiredEnergyKwh} kWh, départ ${ctx.departureTime}${ctx.departurePriority ? ' (priorité départ ON)' : ''}`,\n `- Prévision solaire : ${ctx.todayRemainingKwh} kWh restant aujourd'hui, ${ctx.tomorrowKwh} kWh demain`,\n `- Cible SOC matin actuelle : ${ctx.nightTargetActual}% | HC nuit autorisé : ${ctx.allowGridHcNight ? 'oui' : 'non'}`,\n '',\n 'Quelle est ta recommandation ? JSON uniquement.',\n].join('\\n');\n\nreturn [{\n json: {\n enabled: ctx.advisorEnabled,\n messages: [\n { role: 'system', content: systemPrompt },\n { role: 'user', content: userPrompt },\n ],\n context: ctx,\n }\n}];\n" + } + }, + { + "name": "IF_ENABLED", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [ + 600, + 0 + ], + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "combinator": "and", + "conditions": [ + { + "leftValue": "={{ $json.enabled }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ] + } + } + }, + { + "name": "LLM_LOCAL_MODELS", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 800, + -100 + ], + "parameters": { + "method": "GET", + "url": "http://10.10.10.8:8000/v1/models", + "options": { + "timeout": 5000 + } + }, + "onError": "continueRegularOutput", + "alwaysOutputData": true + }, + { + "name": "LLM_LOCAL", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1000, + -100 + ], + "parameters": { + "method": "POST", + "url": "http://10.10.10.8:8000/v1/chat/completions", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ model: $('LLM_LOCAL_MODELS').first().json.data[0].id, messages: $('BUILD_PROMPT').first().json.messages, temperature: 0.1, max_tokens: 6000 }) }}", + "options": { + "timeout": 120000 + } + }, + "onError": "continueRegularOutput", + "alwaysOutputData": true + }, + { + "name": "IF_LOCAL_OK", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "position": [ + 1200, + -100 + ], + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "combinator": "and", + "conditions": [ + { + "leftValue": "={{ $json.choices !== undefined && $json.choices.length > 0 }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ] + } + } + }, + { + "name": "LLM_CLOUD", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1400, + 40 + ], + "parameters": { + "method": "POST", + "url": "https://openrouter.ai/api/v1/chat/completions", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ model: 'anthropic/claude-haiku-4.5', messages: $('BUILD_PROMPT').first().json.messages, temperature: 0.1, max_tokens: 1000 }) }}", + "options": { + "timeout": 60000 + } + }, + "credentials": { + "httpHeaderAuth": { + "id": "eOM9xofDHT0KMoeY", + "name": "OpenRouter Advisor" + } + }, + "onError": "continueRegularOutput", + "alwaysOutputData": true + }, + { + "name": "VALIDATE_PARSE", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1600, + -30 + ], + "parameters": { + "jsCode": "// =====================================================\n// EVCS ADVISOR SHADOW V1 — VALIDATE_PARSE\n// Valide STRICTEMENT la sortie LLM (locale OU cloud, format OpenAI).\n// Sortie invalide = REJET TOTAL (status invalid), jamais de clamp silencieux.\n// =====================================================\n\nconst resp = $input.first().json;\nconst context = $('BUILD_PROMPT').first().json.context;\n\nconst STRATEGIES = ['auto_solar_plus', 'auto_solar', 'force_grid_now', 'force_charge_now', 'stop'];\nconst TONIGHT = ['oui', 'non', 'sans_objet'];\n\nfunction fail(status, detail) {\n return [{\n json: {\n status,\n detail: (detail || '').toString().slice(0, 200),\n advice: null,\n modelUsed: (resp?.model || 'aucun').toString().slice(0, 60),\n context,\n }\n }];\n}\n\n// 1. Réponse LLM présente ? (les deux backends ont échoué => item d'erreur n8n)\nconst content = resp?.choices?.[0]?.message?.content;\nif (typeof content !== 'string' || !content.trim()) {\n return fail('llm_offline', resp?.error?.message || resp?.error || 'aucune réponse LLM');\n}\n\n// 2. Nettoyage : balises de raisonnement (...) et fences markdown\nlet text = content\n .replace(/[\\s\\S]*?<\\/think>/gi, '')\n .replace(/```(?:json)?/gi, '')\n .trim();\n\n// 3. Extraction du premier objet JSON équilibré\nconst start = text.indexOf('{');\nif (start === -1) return fail('invalid', 'aucun JSON dans la réponse');\nlet depth = 0, end = -1;\nfor (let i = start; i < text.length; i++) {\n if (text[i] === '{') depth++;\n else if (text[i] === '}') { depth--; if (depth === 0) { end = i; break; } }\n}\nif (end === -1) return fail('invalid', 'JSON non terminé');\n\nlet obj;\ntry {\n obj = JSON.parse(text.slice(start, end + 1));\n} catch (e) {\n return fail('invalid', `JSON invalide: ${e.message}`);\n}\n\n// 4. Validation stricte du schéma (enums + bornes)\nconst strategy = (obj.strategy ?? '').toString().trim().toLowerCase();\nif (!STRATEGIES.includes(strategy)) return fail('invalid', `strategy hors enum: ${strategy}`);\n\nconst nightTarget = Number(obj.night_target_soc);\nif (!Number.isFinite(nightTarget) || nightTarget < 22 || nightTarget > 90) {\n return fail('invalid', `night_target_soc hors bornes: ${obj.night_target_soc}`);\n}\n\nconst currentMax = Number(obj.current_max_a);\nif (!Number.isFinite(currentMax) || currentMax < 6 || currentMax > 28) {\n return fail('invalid', `current_max_a hors bornes: ${obj.current_max_a}`);\n}\n\nconst tonight = (obj.ev_charge_tonight ?? '').toString().trim().toLowerCase();\nif (!TONIGHT.includes(tonight)) return fail('invalid', `ev_charge_tonight hors enum: ${tonight}`);\n\nconst confidence = Number(obj.confidence);\nif (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) {\n return fail('invalid', `confidence hors bornes: ${obj.confidence}`);\n}\n\nconst reason = (obj.reason ?? '').toString().trim();\nif (!reason) return fail('invalid', 'reason vide');\n\nreturn [{\n json: {\n status: 'ok',\n detail: '',\n advice: {\n strategy,\n nightTargetSoc: Math.round(nightTarget),\n currentMaxA: Math.round(currentMax),\n evChargeTonight: tonight,\n confidence: Math.round(confidence * 100) / 100,\n reason: reason.slice(0, 250),\n },\n modelUsed: (resp.model || 'inconnu').toString().slice(0, 60),\n context,\n }\n}];\n" + } + }, + { + "name": "BUILD_HA_CALLS", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1800, + -30 + ], + "parameters": { + "jsCode": "// =====================================================\n// EVCS ADVISOR SHADOW V1 — BUILD_HA_CALLS\n// Écrit les helpers advisor_* + divergence + compteurs + logbook.\n// GARANTIE MODE OMBRE : ce node ne produit QUE des écritures vers\n// des entités advisor_* et le logbook. AUCUN switch, AUCUNE stratégie\n// réelle, AUCUNE commande borne. Ne pas ajouter d'autres entités ici.\n// =====================================================\n\nconst { status, detail, advice, modelUsed, context } = $input.first().json;\n\nfunction stampParis() {\n const p = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit',\n hour: '2-digit', minute: '2-digit', hour12: false,\n }).formatToParts(new Date());\n const g = t => p.find(x => x.type === t)?.value || '';\n return `${g('day')}/${g('month')} ${g('hour')}:${g('minute')}`;\n}\n\nconst calls = [];\nfunction push(domain, service, data) {\n calls.push({ json: { domain, service, data } });\n}\nfunction setText(entity, value) {\n push('input_text', 'set_value', { entity_id: entity, value: (value ?? '').toString().slice(0, 250) });\n}\nfunction setNumber(entity, value) {\n push('input_number', 'set_value', { entity_id: entity, value });\n}\n\n// Statut + horodatage + modèle : écrits à CHAQUE run\nsetText('input_text.evcs_advisor_status', status + (detail ? `: ${detail}` : ''));\nsetText('input_text.evcs_advisor_last_run', stampParis());\nsetText('input_text.evcs_advisor_model', modelUsed);\n\nif (status === 'ok' && advice) {\n // Recommandations\n setText('input_text.evcs_advisor_strategy', advice.strategy);\n setNumber('input_number.evcs_advisor_night_target_soc', advice.nightTargetSoc);\n setNumber('input_number.evcs_advisor_current_max_a', advice.currentMaxA);\n setText('input_text.evcs_advisor_ev_charge_tonight', advice.evChargeTonight);\n setText('input_text.evcs_advisor_reason', `[${Math.round(advice.confidence * 100)}%] ${advice.reason}`);\n\n // Divergence vs réel (stratégie + cible nuit)\n const diffs = [];\n if (advice.strategy !== context.userStrategy) {\n diffs.push(`stratégie: agent=${advice.strategy} ≠ réel=${context.userStrategy}`);\n }\n if (Math.abs(advice.nightTargetSoc - context.nightTargetActual) > 10) {\n diffs.push(`cible nuit: agent=${advice.nightTargetSoc}% vs réel=${context.nightTargetActual}%`);\n }\n const divergenceText = diffs.length ? diffs.join(' | ') : 'accord avec le réel';\n setText('input_text.evcs_advisor_divergence', divergenceText);\n\n // Compteurs accord/divergence sur la stratégie (base des critères de bascule)\n if (advice.strategy === context.userStrategy) {\n setNumber('input_number.evcs_advisor_agree_count', context.agreeCount + 1);\n } else {\n setNumber('input_number.evcs_advisor_diverge_count', context.divergeCount + 1);\n }\n\n // Trace logbook pour l'historique long terme\n push('logbook', 'log', {\n name: 'EVCS Advisor (ombre)',\n 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}`,\n entity_id: 'input_text.evcs_advisor_strategy',\n });\n}\n\nreturn calls;\n" + } + }, + { + "name": "Execute HA Service Advisor", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 2000, + -30 + ], + "parameters": { + "method": "POST", + "url": "={{ 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}", + "authentication": "genericCredentialType", + "genericAuthType": "httpBearerAuth", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + }, + { + "name": "accept", + "value": "application/json" + } + ] + }, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.data }}", + "options": { + "timeout": 15000 + } + }, + "credentials": { + "httpBearerAuth": { + "id": "uv3sknYYigxBkaVN", + "name": "Bearer Auth account 2" + } + }, + "onError": "continueRegularOutput" + } + ], + "connections": { + "Schedule Trigger": { + "main": [ + [ + { + "node": "HA Snapshot Advisor", + "type": "main", + "index": 0 + } + ] + ] + }, + "HA Snapshot Advisor": { + "main": [ + [ + { + "node": "BUILD_PROMPT", + "type": "main", + "index": 0 + } + ] + ] + }, + "BUILD_PROMPT": { + "main": [ + [ + { + "node": "IF_ENABLED", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF_ENABLED": { + "main": [ + [ + { + "node": "LLM_LOCAL_MODELS", + "type": "main", + "index": 0 + } + ], + [] + ] + }, + "LLM_LOCAL_MODELS": { + "main": [ + [ + { + "node": "LLM_LOCAL", + "type": "main", + "index": 0 + } + ] + ] + }, + "LLM_LOCAL": { + "main": [ + [ + { + "node": "IF_LOCAL_OK", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF_LOCAL_OK": { + "main": [ + [ + { + "node": "VALIDATE_PARSE", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "LLM_CLOUD", + "type": "main", + "index": 0 + } + ] + ] + }, + "LLM_CLOUD": { + "main": [ + [ + { + "node": "VALIDATE_PARSE", + "type": "main", + "index": 0 + } + ] + ] + }, + "VALIDATE_PARSE": { + "main": [ + [ + { + "node": "BUILD_HA_CALLS", + "type": "main", + "index": 0 + } + ] + ] + }, + "BUILD_HA_CALLS": { + "main": [ + [ + { + "node": "Execute HA Service Advisor", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1", + "callerPolicy": "workflowsFromSameOwner" + }, + "staticData": null, + "id": "tdfInZns7cqo8fU2" +}