SPECIALISATION. Campagne du 12/09 : lui demander d observer ET de planifier degradait les deux — l ecart de cible entre passages est passe de 0 a 35 points sur certains cas des l ajout de la mission d observation. Il ne planifie plus : la planification est faite par le bilan, qui est calcule, mesure et teste a 48/48. Sortie reduite a constat / gravite / ce_qui_cloche / piste. Prompt 7609 -> 6250 caracteres. Mesure apres specialisation, horloge epinglee : oscillation reelle detectee 5/5, contrefactuel calme 5/5. La discrimination est parfaite. ⚠️ Mais la gravite est retombee de « haute » a « moyenne » : mon echelle a quatre niveaux n est ancree sur aucun exemple. Defaut de briefing a corriger, pas defaut de detection. Le compteur mesure desormais le VOLUME de signalements : on ne peut pas verifier automatiquement s il a raison, on peut mesurer s il crie au loup. COUCHE D ENQUETE. Trouver le coupable ce matin a demande sept requetes enchainees ; rien n y exigeait un humain, seulement d appeler des outils et d enchainer d apres ce qu on vient de lire. Six sous-commandes en LECTURE SEULE, exposees en ligne de commande pour qu OpenClaw — que le proprietaire veut utiliser — ou tout autre pilote les appelle sans reecriture : bascules, mesures, decisions, ecrivains, bilan, cherche. `bascules` calcule l ecart entre transitions successives du meme objet : c est la signature d une oscillation, et l appelant n a pas a la refaire. L enquete de ce matin se rejoue en trois commandes, jusqu a automations.yaml ligne 22. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj
154 lines
8.4 KiB
JavaScript
154 lines
8.4 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":"","piste":"","confidence":0.85}';
|
|
|
|
const cases = [
|
|
{
|
|
label: 'réponse valide → ok',
|
|
resp: { model: 'qwen3.6-35b', choices: [{ message: { content: validAdvice } }] },
|
|
expect: o => o.status === 'ok' && o.advice.gravite === 'aucune' && o.advice.constat.includes('stable'),
|
|
},
|
|
{
|
|
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.confidence === 0.85,
|
|
},
|
|
{
|
|
label: 'confidence hors bornes → invalid',
|
|
resp: { model: 'x', choices: [{ message: { content: validAdvice.replace('"confidence":0.85', '"confidence":7') } }] },
|
|
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 constat + reason', calls.some(c => (c.data.entity_id || '').includes('advisor_constat')) && calls.some(c => (c.data.entity_id || '').includes('advisor_reason')));
|
|
|
|
// ── LE COMPTEUR MESURE LE VOLUME DE SIGNALEMENTS (12/09/2026) ─────────
|
|
// Un observateur qui signale en permanence ne sert a rien. On ne peut pas
|
|
// mesurer automatiquement s il a RAISON ; on peut mesurer s il crie au loup.
|
|
function compteur(json) {
|
|
const v = run(VALIDATE_PARSE, { model: 'x', choices: [{ message: { content: json } }] }, ctxNode)[0].json;
|
|
const out = run(BUILD_HA_CALLS, v, ctxNode).map(i => i.json);
|
|
const id = k => out.some(c => (c.data.entity_id || '').includes(k));
|
|
return { calme: id('agree_count'), signale: id('diverge_count') };
|
|
}
|
|
const rienASignaler = compteur(validAdvice);
|
|
check('COMPTEUR: gravite aucune → compte comme calme', rienASignaler.calme && !rienASignaler.signale);
|
|
const signalement = compteur(validAdvice.replace('"gravite":"aucune"', '"gravite":"haute"'));
|
|
check('COMPTEUR: gravite haute → compte comme signalement', signalement.signale && !signalement.calme);
|
|
|
|
// ── 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');
|
|
|
|
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 casHorsLigne = cases.find(c => c.label.includes('llm_offline'));
|
|
const offlineValidated = run(VALIDATE_PARSE, casHorsLigne.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_constat')) && 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);
|