Files
EVCS/tests/advisor/run.mjs
Tallagrand Thierry 3cde737af8 Le conseiller recoit enfin de quoi decider
Diagnostic du proprietaire, et il est juste : le conseiller n est pas
utilisable parce qu on lui donne des consignes fausses et des informations
partielles.

CONSIGNES FAUSSES. Son briefing enoncait mot pour mot la regle des paliers
(« forecast fort >= 18 kWh => cible ~35 % »), celle que le bilan a demontree
fausse le 03/09. Il declarait 17,5 kWh de batterie (mesure : 6,8 sur quatre
modules) et 9 kWc de panneaux (16,1 en realite). Retires. La capacite est
desormais CALCULEE depuis la courbe mesuree et le parc declare.

INFORMATIONS PARTIELLES. Il ne recevait AUCUNE sortie du bilan. Il ignorait
donc l heure de croisement, l energie a couvrir et le SOC requis -- les
seules grandeurs sur lesquelles la question se decide. +23 champs au
snapshot : bilan complet, parc, limites BMS, consigne reseau, modes des
chargeurs solaires, vehicule selectionne.

Ajoute aussi ce que personne ne lui disait : sans vehicule branche il y a
quand meme quelque chose a decider (c est la maison qui traverse la nuit),
les chargeurs declarent eux-memes s ils se brident, la cuisson du soir se
decale de trois heures, l hiver la nuit est plus longue de deux heures en
temps solaire.

PREUVE. Rejeu de la nuit bloquee du 12/09 a 04:00, sans aucune aide :
  avant  auto_solar_plus, cible 35 %, « prevision forte => cible basse »
  apres  force_grid_now,  cible 60 %, « bilan exige 3.13 kWh avant 09h »
Le bilan demandait 59,1 %. En production a la meme heure, il n avait rien vu
-- quatre fois de suite.

COMPTEUR. Il comparait la STRATEGIE, constante a auto_solar_plus : 517
accords contre 23, soit 95,7 % — un score qui mesurait une constante. Il
compare desormais la CIBLE, contre le BILAN, et ne compte rien quand le
bilan est indisponible plutot que de compter faux.

Tests advisor : 3 nouveaux sur le compteur (ecart 2 pts -> accord, 13 pts ->
divergence, bilan indisponible -> aucun comptage). Perimetre mode ombre
inchange et toujours verifie.

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

147 lines
7.6 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');
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', 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);
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);