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:
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);
|
||||
Reference in New Issue
Block a user