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:
58
HANDOFF.md
58
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) —
|
- 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.
|
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 <think>, 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/
|
||||||
|
<think>/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é.
|
||||||
|
|
||||||
|
|||||||
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,
|
||||||
|
}
|
||||||
|
}];
|
||||||
123
tests/advisor/run.mjs
Normal file
123
tests/advisor/run.mjs
Normal file
@@ -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 <think> + fences markdown → ok',
|
||||||
|
resp: { model: 'qwen3-vl', choices: [{ message: { content: `<think>je réfléchis...\nblabla</think>\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);
|
||||||
214
tools/gen-advisor-workflow.mjs
Normal file
214
tools/gen-advisor-workflow.mjs
Normal file
@@ -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');
|
||||||
@@ -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).
|
// 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 WORKFLOWS = [
|
||||||
export const SRC_DIR = 'src/nodes';
|
{
|
||||||
|
id: 'evcs',
|
||||||
export const NODE_MAP = [
|
workflowFile: 'workflows/evcs-surplus-controller-v2.6.json',
|
||||||
{ file: 'INPUT_PARSE.js', node: 'INPUT_PARSE', kind: 'jsCode' },
|
srcDir: 'src/nodes',
|
||||||
{ file: 'WINDOWS_SOURCEV2.js', node: 'WINDOWS_SOURCEV2', kind: 'jsCode' },
|
nodes: [
|
||||||
{ file: 'BATTERY_CLOUD_LOCKS.js', node: 'BATTERY_CLOUD_LOCKS', kind: 'jsCode' },
|
{ file: 'INPUT_PARSE.js', node: 'INPUT_PARSE', kind: 'jsCode' },
|
||||||
{ file: 'DECISION_ENGINEV2.js', node: 'DECISION_ENGINEV2', kind: 'jsCode' },
|
{ file: 'WINDOWS_SOURCEV2.js', node: 'WINDOWS_SOURCEV2', kind: 'jsCode' },
|
||||||
{ file: 'HA_SERVICE_CALLS_OUTPUT.js', node: 'HA_SERVICE_CALLS_OUTPUT', kind: 'jsCode' },
|
{ file: 'BATTERY_CLOUD_LOCKS.js', node: 'BATTERY_CLOUD_LOCKS', kind: 'jsCode' },
|
||||||
{ file: 'BUILD_ACTIONS.js', node: 'Build EVCS Surplus Controller V2.0 Actions', kind: 'jsCode' },
|
{ file: 'DECISION_ENGINEV2.js', node: 'DECISION_ENGINEV2', kind: 'jsCode' },
|
||||||
{ file: 'HA_SNAPSHOT_TEMPLATE.json.tpl', node: 'HA Snapshot EVCS Surplus Controller V2.2', kind: 'template' },
|
{ 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) {
|
export function getCode(workflowNode, kind) {
|
||||||
if (kind === 'jsCode') return workflowNode.parameters.jsCode;
|
if (kind === 'jsCode') return workflowNode.parameters.jsCode;
|
||||||
if (kind === 'template') {
|
if (kind === 'template') {
|
||||||
@@ -30,3 +51,8 @@ export function setCode(workflowNode, kind, code) {
|
|||||||
}
|
}
|
||||||
throw new Error(`kind inconnu: ${kind}`);
|
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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,38 +1,46 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// R2 — BUILD : src/nodes/*.js → workflow JSON (src/ = SOURCE DE VÉRITÉ, src gagne).
|
// R2 — BUILD : src/ → workflows JSON (src = SOURCE DE VÉRITÉ, src gagne).
|
||||||
// Usage : node tools/n8n-build.mjs [--check]
|
// Usage : node tools/n8n-build.mjs [--check] [fichierWorkflow]
|
||||||
// --check : vérifie que le JSON est déjà synchronisé avec src/ (exit 1 sinon),
|
// --check : vérifie la synchro sans écrire (exit 1 si désynchronisé)
|
||||||
// sans rien écrire.
|
// fichierWorkflow : limite à un workflow (sinon tous ceux du node-map)
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
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 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;
|
let totalChanges = 0;
|
||||||
for (const { file, node, kind } of NODE_MAP) {
|
for (const w of targets) {
|
||||||
const srcPath = path.join(SRC_DIR, file);
|
if (!fs.existsSync(w.workflowFile)) {
|
||||||
if (!fs.existsSync(srcPath)) throw new Error(`Fichier source manquant: ${srcPath}`);
|
console.log(`(skip ${w.id}: ${w.workflowFile} absent)`);
|
||||||
const srcCode = fs.readFileSync(srcPath, 'utf8');
|
continue;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
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) {
|
if (CHECK) process.exit(totalChanges ? 1 : 0);
|
||||||
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é)');
|
|
||||||
}
|
|
||||||
|
|||||||
37
tools/n8n-create.mjs
Normal file
37
tools/n8n-create.mjs
Normal file
@@ -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 <fichier.json>
|
||||||
|
|
||||||
|
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 <fichier.json>'); 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}`);
|
||||||
@@ -1,21 +1,27 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// R2 — EXTRACT : workflow JSON → src/nodes/*.js (+ template snapshot).
|
// R2 — EXTRACT : workflows JSON → src/ (resynchronisation après édition UI n8n — à éviter).
|
||||||
// À utiliser UNIQUEMENT pour (re)synchroniser src/ après une modification faite
|
// Usage : node tools/n8n-extract.mjs [fichierWorkflow]
|
||||||
// directement dans l'UI n8n (à éviter). En temps normal : éditer src/nodes/,
|
|
||||||
// puis tools/n8n-push.mjs reconstruit le JSON automatiquement.
|
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
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'));
|
const fileArg = process.argv.slice(2).find(a => !a.startsWith('--'));
|
||||||
fs.mkdirSync(SRC_DIR, { recursive: true });
|
const targets = fileArg ? [findWorkflowByFile(fileArg)].filter(Boolean) : WORKFLOWS;
|
||||||
|
|
||||||
for (const { file, node, kind } of NODE_MAP) {
|
for (const w of targets) {
|
||||||
const n = wf.nodes.find(x => x.name === node);
|
if (!fs.existsSync(w.workflowFile)) {
|
||||||
if (!n) throw new Error(`Node introuvable: ${node}`);
|
console.log(`(skip ${w.id}: ${w.workflowFile} absent)`);
|
||||||
const code = getCode(n, kind);
|
continue;
|
||||||
fs.writeFileSync(path.join(SRC_DIR, file), code);
|
}
|
||||||
console.log(`${file} ← "${node}" (${code.length} chars)`);
|
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');
|
||||||
|
|||||||
@@ -15,14 +15,14 @@ if (!INPUT) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// R2 : src/nodes/ = SOURCE DE VÉRITÉ pour le workflow EVCS.
|
// R2 : src/ = SOURCE DE VÉRITÉ pour les workflows enregistrés dans le node-map.
|
||||||
// Le build injecte src/nodes/*.js dans le JSON AVANT toute vérification :
|
// Le build injecte src/**/*.js dans le JSON AVANT toute vérification.
|
||||||
// toute édition faite directement dans le JSON (ou l'UI n8n non ré-extraite)
|
const { findWorkflowByFile } = await import('./lib-node-map.mjs');
|
||||||
// est écrasée par src/. Workflow EVCS uniquement.
|
const registered = findWorkflowByFile(INPUT);
|
||||||
if (path.basename(INPUT) === 'evcs-surplus-controller-v2.6.json' && fs.existsSync('src/nodes')) {
|
if (registered) {
|
||||||
console.log('Build src/nodes/ → JSON (source de vérité : src/)...');
|
console.log(`Build ${registered.srcDir}/ → JSON (source de vérité : src/)...`);
|
||||||
const { spawnSync } = await import('child_process');
|
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) {
|
if (b.status !== 0) {
|
||||||
console.error('ABANDON: échec du build src → JSON.');
|
console.error('ABANDON: échec du build src → JSON.');
|
||||||
process.exit(1);
|
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 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() {
|
function loadApiConfig() {
|
||||||
if (process.env.N8N_API_KEY) {
|
if (process.env.N8N_API_KEY) {
|
||||||
@@ -101,20 +105,25 @@ if (syntaxErrors) {
|
|||||||
}
|
}
|
||||||
console.log('Syntaxe OK sur tous les nodes jsCode.');
|
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 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');
|
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) {
|
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 {
|
} 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 { 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) {
|
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('(É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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
421
workflows/evcs-advisor-shadow-v1.json
Normal file
421
workflows/evcs-advisor-shadow-v1.json
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user