Files
EVCS/tests/advisor/run.mjs
Tallagrand Thierry 040d2fc730 Le conseiller passe de planificateur a observateur
Idee du proprietaire, et elle est meilleure que la mienne : ce systeme
n a pas besoin qu on lui suggere une strategie de plus, il a besoin que
quelqu un VOIE que « je coupe EDF, non je remets, et hop je coupe » est
absurde. Cinq automates ecrivent sur la meme installation, chacun
localement correct, aucun ne voit les autres. Leurs pannes sont des
CONTRADICTIONS, et elles ne se lisent que dans la SUITE des evenements —
jamais dans un instantane.

Le 12/09 au matin le contacteur reseau a bascule toutes les 4 minutes
pendant dix minutes : une automatisation HA coupait a 35 % de SOC,
l hysteresis rallumait. Aucun automate ne l a vu ; un humain oui.

Sortie elargie : `constat`, `gravite` (aucune/faible/moyenne/haute) et
`ce_qui_cloche`, valides strictement, publies dans trois helpers et dans
le logbook. Le snapshot porte desormais l anciennete des cinq transitions
cles (contacteur, verrou, takeover, etat EVCS, mode ACM).

⚠️ VALIDATION SCINDEE. Essai sur l oscillation reelle : le modele a
produit un constat JUSTE et un `current_max_a` de 100, hors bornes. La
validation tout-ou-rien jetait le diagnostic avec le champ fautif —
c est-a-dire exactement ce qu on lui demande, perdu a cause de ce qu on
ne lui demande plus. Les champs d ACTION gardent leurs bornes mais sont
NEUTRALISES au lieu de faire echouer l observation ; `actionUtilisable`
est le seul drapeau qui autoriserait un jour un cablage, et les compteurs
ne comptent que lui.

Tests advisor : +6. Deux attendus mis a la nouvelle semantique dans le
meme commit (hors bornes -> observation gardee, action neutralisee).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj
2026-09-12 07:07:42 +02:00

191 lines
11 KiB
JavaScript

#!/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');
// La borne de 6000 datait du modele local d aout (contexte etroit). Le modele
// charge aujourd hui annonce 98 304 tokens ; la vraie contrainte est le cout
// de latence, pas le contexte. On garde une borne pour eviter la derive du
// briefing, elargie a la mission d observation et a l historique des evenements.
check('BUILD_PROMPT: prompt < 9000 chars (garde-fou contre la derive)', (promptOut.messages[0].content.length + promptOut.messages[1].content.length) < 9000);
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 = '{"constat":"Contacteur reseau stable, batterie en charge.","gravite":"aucune","ce_qui_cloche":"","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,
},
{
// ⚠️ SEMANTIQUE CHANGEE LE 12/09 : un champ d ACTION hors bornes ne fait
// plus echouer la reponse — il est NEUTRALISE et l observation survit.
// Le constat est devenu la sortie principale ; le perdre a cause d un
// champ qu on ne demande plus serait absurde. `actionUtilisable` reste
// le seul drapeau qui autoriserait un cablage.
label: 'night_target_soc 15 (< plancher 22) → observation gardee, action neutralisee',
resp: { model: 'x', choices: [{ message: { content: validAdvice.replace('"night_target_soc":35', '"night_target_soc":15') } }] },
expect: o => o.status === 'ok' && o.advice.nightTargetSoc === null && o.advice.actionUtilisable === false && o.advice.constat,
},
{
label: 'current_max_a 40 (> 28) → observation gardee, action neutralisee',
resp: { model: 'x', choices: [{ message: { content: validAdvice.replace('"current_max_a":22', '"current_max_a":40') } }] },
expect: o => o.status === 'ok' && o.advice.currentMaxA === null && o.advice.actionUtilisable === false,
},
{
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', calls.some(c => (c.data.entity_id || '').includes('advisor_strategy')) && calls.some(c => (c.data.entity_id || '').includes('advisor_reason')));
// ── LE COMPTEUR MESURE LA CIBLE, CONTRE LE BILAN (12/09/2026) ──────────
// Il comparait la STRATEGIE, qui vaut auto_solar_plus la quasi-totalite du
// temps : 517/23, soit 95,7 % — un score qui mesurait une constante. La cible,
// seul champ cable, divergeait de 13 points du bilan sans etre comptee.
function compteur(ctx) {
const node = { BUILD_PROMPT: { ...promptOut, context: ctx } };
const validated = run(VALIDATE_PARSE, cases[0].resp, node)[0].json;
const out = run(BUILD_HA_CALLS, validated, node).map(i => i.json);
const id = k => out.some(c => (c.data.entity_id || '').includes(k));
return { accord: id('agree_count'), divergence: id('diverge_count'), ecart: id('advisor_divergence') };
}
const base = promptOut.context;
// L'agent dit 35 (le validAdvice des cas ci-dessus).
const proche = compteur({ ...base, balanceStatus: 'ok', balanceRequiredSoc: 33 });
check('COMPTEUR: cible a 2 points du bilan → accord', proche.accord && !proche.divergence);
const loin = compteur({ ...base, balanceStatus: 'ok', balanceRequiredSoc: 48.4 });
check('COMPTEUR: cible a 13 points du bilan → divergence (le cas reel du 12/09)', loin.divergence && !loin.accord);
const sansBilan = compteur({ ...base, balanceStatus: 'degrade', balanceRequiredSoc: 48.4 });
check('COMPTEUR: bilan indisponible → on ne compte RIEN plutot que de compter faux',
!sansBilan.accord && !sansBilan.divergence);
// ── OBSERVATION : le constat et la gravite sont obligatoires ───────────
function valider(json) {
return run(VALIDATE_PARSE, { model: 'x', choices: [{ message: { content: json } }] }, ctxNode)[0].json;
}
const osc = valider(validAdvice
.replace('"constat":"Contacteur reseau stable, batterie en charge."', '"constat":"Le contacteur reseau a bascule 4 fois en 10 minutes."')
.replace('"gravite":"aucune"', '"gravite":"haute"')
.replace('"ce_qui_cloche":""', '"ce_qui_cloche":"deux ecrivains avec des seuils differents sur le meme actionneur"'));
check('OBSERVATION: constat + gravite haute acceptes',
osc.status === 'ok' && osc.advice.gravite === 'haute' && osc.advice.constat.includes('bascule'));
check('OBSERVATION: constat vide → REJET',
valider(validAdvice.replace('"constat":"Contacteur reseau stable, batterie en charge."', '"constat":""')).status === 'invalid');
check('OBSERVATION: gravite hors enum → REJET',
valider(validAdvice.replace('"gravite":"aucune"', '"gravite":"catastrophique"')).status === 'invalid');
// Le constat doit survivre a un champ d action hors bornes.
const horsBornes = valider(validAdvice
.replace('"constat":"Contacteur reseau stable, batterie en charge."', '"constat":"Bascule du contacteur toutes les 1-2 min depuis 06:25."')
.replace('"gravite":"aucune"', '"gravite":"haute"')
.replace('"current_max_a":22', '"current_max_a":100'));
check('OBSERVATION: champ action hors bornes → le constat SURVIT',
horsBornes.status === 'ok' && horsBornes.advice.constat.includes('Bascule') && horsBornes.advice.actionUtilisable === false);
const hbCalls = run(BUILD_HA_CALLS, horsBornes, ctxNode).map(i => i.json);
check('OBSERVATION: action hors bornes → aucun helper numerique ecrit',
hbCalls.some(c => (c.data.entity_id || '').includes('advisor_constat')) &&
!hbCalls.some(c => (c.data.entity_id || '').includes('night_target_soc')) &&
!hbCalls.some(c => (c.data.entity_id || '').includes('agree_count')));
const oscCalls = run(BUILD_HA_CALLS, osc, ctxNode).map(i => i.json);
check('OBSERVATION: constat, gravite et diagnostic publies',
['constat', 'gravite', 'ce_qui_cloche'].every(k => oscCalls.some(c => (c.data.entity_id || '').includes(`advisor_${k}`))));
check('OMBRE: l observation ne sort pas du perimetre advisor_*',
oscCalls.every(c => c.domain === 'logbook' || /^(input_text|input_number)\.evcs_advisor_/.test(c.data.entity_id || '')));
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);