Le conseiller devient observateur, et une couche d enquete voit le jour

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
This commit is contained in:
Tallagrand Thierry
2026-09-12 08:36:31 +02:00
parent 6a647dc685
commit 1945fdfe5c
9 changed files with 4586 additions and 185 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
{
"genere": "2026-09-12T05:19:01.112Z",
"genere": "2026-09-12T06:21:15.701Z",
"carte": {
"switch.edf_general_ok_switch": {
"libelle": "Contacteur reseau EDF",

View File

@@ -34,64 +34,30 @@ setText('input_text.evcs_advisor_last_run', stampParis());
setText('input_text.evcs_advisor_model', modelUsed);
if (status === 'ok' && advice) {
// Observation — la sortie principale desormais.
// Observation — la seule sortie desormais. Le conseiller ne planifie plus :
// la planification est faite par le bilan, qui est calcule et mesure, et la
// campagne du 12/09 a montre que lui demander les deux degradait les deux
// (ecart de cible passe de 0 a 35 points sur certains cas des l ajout de la
// mission d observation).
setText('input_text.evcs_advisor_constat', advice.constat);
setText('input_text.evcs_advisor_gravite', advice.gravite);
setText('input_text.evcs_advisor_ce_qui_cloche', advice.ceQuiCloche || 'rien de signale');
setText('input_text.evcs_advisor_reason', `[${Math.round(advice.confidence * 100)}%] ${advice.piste || advice.constat}`);
// Recommandations
setText('input_text.evcs_advisor_strategy', advice.strategy);
// Les champs d action ne sont publies que s ils sont exploitables : un
// helper numerique ne doit jamais porter une valeur hors bornes.
if (advice.actionUtilisable) {
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);
// ⚠️ LE COMPTEUR MESURAIT LE MAUVAIS CHAMP (corrige le 12/09/2026).
// Il comparait la STRATEGIE, qui vaut `auto_solar_plus` la quasi-totalite du
// temps : 517 accords contre 23 divergences, soit 95,7 % — un score qui
// mesurait une constante, pas une decision. Pendant ce temps la cible SOC,
// seul champ reellement cable, divergeait de treize points du bilan sans
// que rien ne le compte (12/09 05:30 : conseiller 35 %, bilan 48,4 %).
//
// On compte desormais l'accord sur la CIBLE, et contre le BILAN — la
// reference, pas contre la valeur appliquee qui peut elle-meme etre fausse.
// Bilan indisponible => on ne compte rien plutot que de compter faux.
const refSoc = Number(context.balanceRequiredSoc);
const bilanUtilisable = advice.actionUtilisable && context.balanceStatus === 'ok' && Number.isFinite(refSoc);
if (bilanUtilisable) {
const ecart = Math.abs(advice.nightTargetSoc - refSoc);
if (ecart <= 5) {
// Un observateur qui signale en permanence ne sert a rien : on compte le
// VOLUME de signalements pour pouvoir le constater.
if (advice.gravite === 'aucune') {
setNumber('input_number.evcs_advisor_agree_count', context.agreeCount + 1);
} else {
setNumber('input_number.evcs_advisor_diverge_count', context.divergeCount + 1);
}
setText(
'input_text.evcs_advisor_divergence',
`cible: agent=${advice.nightTargetSoc}% vs bilan=${Math.round(refSoc)}% (ecart ${Math.round(ecart)} pts)`
+ (diffs.length ? ` | ${diffs.join(' | ')}` : '')
);
}
// Trace logbook pour l'historique long terme
push('logbook', 'log', {
name: 'EVCS Advisor (ombre)',
message: `[${advice.gravite}] ${advice.constat}${advice.ceQuiCloche ? ` || ${advice.ceQuiCloche}` : ''} || ${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',
name: 'EVCS Advisor (observateur)',
message: `[${advice.gravite}] ${advice.constat}`
+ (advice.ceQuiCloche ? ` || ${advice.ceQuiCloche}` : '')
+ (advice.piste ? ` || piste : ${advice.piste}` : ''),
entity_id: 'input_text.evcs_advisor_constat',
});
}

View File

@@ -192,45 +192,20 @@ const systemPrompt = [
'est un ecrivain AUTRE QUE SON PROPRIETAIRE. Nomme-le.',
// CARTE GENEREE >>>
'',
'STRATEGIES POSSIBLES :',
'- auto_solar_plus : automatique, solaire prioritaire, reseau en heures creuses si besoin',
'- auto_solar : solaire strict, jamais de reseau',
'- force_grid_now : charge sur reseau EDF, immediate',
'- force_charge_now : charge locale immediate quelle que soit la source',
'- stop : tout couper',
'',
'TA MISSION PRINCIPALE : REPERER CE QUI CLOCHE.',
'Cinq automates ecrivent sur la meme installation : le controleur EVCS, un',
'automate d hysteresis pour le contacteur reseau, un gestionnaire de charge',
'batterie, un bilan energetique, et des automatisations Home Assistant. Chacun',
'est localement correct et aucun ne voit les autres. Les pannes de ce systeme',
'ne sont donc presque jamais des erreurs de calcul : ce sont des CONTRADICTIONS',
'entre automates, et elles se voient dans la SUITE des evenements, pas dans',
'un instantane.',
'',
'Exemple reel du 12/09 : le contacteur reseau a bascule toutes les 4 minutes',
'pendant dix minutes — coupe, remis, coupe. Un automate coupait a 35 % de SOC,',
'un autre rallumait. Chacun faisait son travail. Le resultat etait absurde, et',
'seul un humain qui regardait l a vu.',
'',
'Un comportement est ABSURDE quand : un actionneur bascule plusieurs fois en',
'peu de temps ; deux decisions se contredisent ; une grandeur reste figee alors',
'que ce qui la pilote bouge ; une action est repetee sans effet ; une valeur',
'contredit ce que le bilan demande.',
'',
'Tu as le DERNIER HISTORIQUE DES EVENEMENTS plus bas. Sers-t en.',
'TU NE PILOTES RIEN ET TU NE CHOISIS AUCUNE STRATEGIE.',
'La planification energetique est faite par le bilan, qui est calcule et mesure.',
'Ton seul travail est de REGARDER LE SYSTEME SE COMPORTER et de dire ce qui cloche.',
'',
'Reponds UNIQUEMENT avec un objet JSON valide, sans texte autour, au format EXACT :',
'{"constat":"ce que tu observes, factuel, max 200 caracteres",',
'"gravite":"aucune|faible|moyenne|haute",',
'"ce_qui_cloche":"la contradiction ou l incoherence, ou \'\' si rien",',
'"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 francais (max 200 caracteres)"}',
'"piste":"ce qu il faudrait verifier ou corriger, ou \'\' si rien",',
'"confidence":<0.0-1.0>}',
'',
'gravite "aucune" quand tout est coherent — c est le cas le plus frequent et',
'il ne faut pas inventer un probleme pour justifier une reponse.',
'gravite "aucune" quand tout est coherent — c est le cas le plus frequent et de',
'loin. N invente pas un probleme pour justifier une reponse : un observateur qui',
'signale en permanence ne sert a rien.',
].join('\n');
function nombre(v, unite, defaut) {

View File

@@ -7,8 +7,6 @@
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 [{
@@ -52,36 +50,6 @@ try {
}
// 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 nightTargetRaw = Number(obj.night_target_soc);
const nightTargetValide = Number.isFinite(nightTargetRaw) && nightTargetRaw >= 22 && nightTargetRaw <= 90;
const nightTarget = nightTargetValide ? nightTargetRaw : null;
// ⚠️ L OBSERVATION NE DOIT PAS ETRE PERDUE A CAUSE D UN CHAMP D ACTION.
// Essai du 12/09 : sur l oscillation du contacteur, le modele a produit un
// constat JUSTE (« bascule toutes les 1-2 min depuis 06:25, gravite haute »)
// ET un `current_max_a` de 100, hors bornes. Une 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 restent bornes aussi strictement qu avant ; seulement,
// hors bornes, ils sont NEUTRALISES au lieu de faire echouer le constat.
const currentMaxRaw = Number(obj.current_max_a);
const currentMaxValide = Number.isFinite(currentMaxRaw) && currentMaxRaw >= 6 && currentMaxRaw <= 28;
const currentMax = currentMaxValide ? currentMaxRaw : null;
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');
// ── OBSERVATION (12/09/2026) ───────────────────────────────────────────
// La mission principale du conseiller n est plus de choisir une strategie
// parmi cinq, mais de REPERER CE QUI CLOCHE. Les pannes de ce systeme sont
@@ -95,6 +63,12 @@ if (!constat) return fail('invalid', 'constat vide');
const gravite = (obj.gravite ?? '').toString().trim().toLowerCase();
if (!GRAVITES.includes(gravite)) return fail('invalid', `gravite hors enum: ${obj.gravite}`);
const ceQuiCloche = (obj.ce_qui_cloche ?? '').toString().trim();
const piste = (obj.piste ?? '').toString().trim();
const confidence = Number(obj.confidence);
if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) {
return fail('invalid', `confidence hors bornes: ${obj.confidence}`);
}
return [{
json: {
@@ -104,15 +78,8 @@ return [{
constat: constat.slice(0, 200),
gravite,
ceQuiCloche: ceQuiCloche.slice(0, 200),
strategy,
nightTargetSoc: nightTarget === null ? null : Math.round(nightTarget),
currentMaxA: currentMax === null ? null : Math.round(currentMax),
// Vrai seulement si TOUS les champs d action sont dans leurs bornes.
// C est ce drapeau, et lui seul, qui autoriserait un jour un cablage.
actionUtilisable: currentMaxValide && nightTargetValide,
evChargeTonight: tonight,
piste: piste.slice(0, 200),
confidence: Math.round(confidence * 100) / 100,
reason: reason.slice(0, 250),
},
modelUsed: (resp.model || 'inconnu').toString().slice(0, 60),
context,

View File

@@ -65,39 +65,24 @@ check('BUILD_PROMPT: kill-switch OFF → enabled=false', killSwitchOut.enabled =
// ---------- 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 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.strategy === 'auto_solar_plus' && o.advice.nightTargetSoc === 35,
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.evChargeTonight === 'oui',
expect: o => o.status === 'ok' && o.advice.confidence === 0.85,
},
{
label: 'stratégie hors enum → invalid (rejet)',
resp: { model: 'x', choices: [{ message: { content: validAdvice.replace('auto_solar_plus', 'charge_maximale') } }] },
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,
},
{
// ⚠️ 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.' } }] },
@@ -121,31 +106,21 @@ 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')));
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 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);
// ── 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 { accord: id('agree_count'), divergence: id('diverge_count'), ecart: id('advisor_divergence') };
return { calme: id('agree_count'), signale: id('diverge_count') };
}
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 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) {
@@ -163,28 +138,16 @@ check('OBSERVATION: constat vide → REJET',
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 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_strategy')) && offlineCalls.some(c => (c.data.value || '').toString().startsWith('llm_offline')));
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);

161
tools/enquete.mjs Normal file
View File

@@ -0,0 +1,161 @@
#!/usr/bin/env node
// COUCHE D OUTILS D ENQUETE — lecture seule.
//
// Le 12/09, trouver pourquoi le contacteur reseau oscillait a demande sept
// requetes enchainees : la table des evenements, les mesures, les executions
// des deux workflows, la liste des ecrivains, puis automations.yaml. Rien dans
// cette enquete n exigeait un humain — seulement d appeler des outils et
// d enchainer d apres ce qu on vient de lire.
//
// Ces sous-commandes sont donc exposees en LIGNE DE COMMANDE, pas enfouies dans
// un script qui embarquerait son propre modele : OpenClaw, un agent local ou
// n importe quel autre pilote doivent pouvoir les appeler sans reecriture.
//
// LECTURE SEULE, sans exception. Aucune sous-commande n ecrit quoi que ce soit.
//
// node tools/enquete.mjs outils
// node tools/enquete.mjs bascules switch.edf_general_ok_switch 3
// node tools/enquete.mjs mesures soc,grid,pv "2026-09-12 06:20" "2026-09-12 06:40"
// node tools/enquete.mjs decisions ATswvGfG4KJUls0K "06:24" "06:27"
// node tools/enquete.mjs ecrivains switch.edf_general_ok_switch
// node tools/enquete.mjs bilan 6
// node tools/enquete.mjs cherche "35"
import fs from 'fs';
import os from 'os';
import path from 'path';
import { execFileSync } from 'child_process';
const HA = '/mnt/ARCH3/docker/HA';
const PG = { h: '10.10.10.22', p: '5433', u: 'evcs_lecture', d: 'evcs' };
const ALIAS = {
soc: 'sensor.victron_battery_soc',
courant: 'sensor.victron_battery_current',
pv: 'sensor.victron_pv_power_system',
maison: 'sensor.victron_consumption_power_l1',
grid: 'sensor.victron_grid_power_l1',
evcs: 'sensor.victron_evcs_ac_power',
consigne: 'number.gx_device_ac_power_setpoint',
};
function motDePasse() {
const env = fs.readFileSync('.env', 'utf8');
const m = env.match(/^PG_LECTURE_PASSWORD=(.*)$/m);
return (m ? m[1] : '').trim().replace(/^["']|["']$/g, '');
}
function sql(requete) {
const out = execFileSync('psql', ['-h', PG.h, '-p', PG.p, '-U', PG.u, '-d', PG.d, '-At', '-F', '\t', '-c', requete],
{ env: { ...process.env, PGPASSWORD: motDePasse() }, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 });
return out.trim().split('\n').filter(Boolean).map(l => l.split('\t'));
}
function cleN8n() {
const c = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.config/opencode/opencode.json'), 'utf8'));
const e = c?.mcp?.['n8n-mcp']?.environment || {};
return { url: (e.N8N_API_URL || '').replace(/\/api\/v1\/?$/, ''), key: e.N8N_API_KEY };
}
const sortie = o => console.log(JSON.stringify(o, null, 2));
const [, , commande, ...args] = process.argv;
if (!commande || commande === 'outils') {
sortie({
lecture_seule: true,
outils: [
{ nom: 'bascules', usage: 'bascules <entite|motif> [heures=6]', quoi: 'transitions enregistrees d un actionneur ou d un etat' },
{ nom: 'mesures', usage: 'mesures <cles> <debut> [fin]', quoi: 'grandeurs mesurees sur une fenetre', alias: Object.keys(ALIAS) },
{ nom: 'decisions', usage: 'decisions <workflowId> <hhmm_debut> <hhmm_fin> [date]', quoi: 'ce qu un workflow n8n a decide, cycle par cycle' },
{ nom: 'ecrivains', usage: 'ecrivains <entite>', quoi: 'qui peut ecrire sur un actionneur, et qui en est le proprietaire declare' },
{ nom: 'bilan', usage: 'bilan [heures=6]', quoi: 'ce que le bilan energetique demandait' },
{ nom: 'cherche', usage: 'cherche <motif>', quoi: 'ou ce motif apparait dans les automatisations, scripts et paquets Home Assistant' },
],
});
} else if (commande === 'bascules') {
const [motif, h = '6'] = args;
const r = sql(`select to_char(ts at time zone 'Europe/Paris','YYYY-MM-DD HH24:MI:SS'), genre, resume
from evenement where ts > now() - interval '${Number(h)} hours'
${motif ? `and resume ilike '%${motif.replace(/'/g, '')}%'` : ''}
order by ts desc limit 80`);
const lignes = r.map(([quand, genre, resume]) => ({ quand, genre, resume }));
// Un ecart court entre deux transitions du meme objet est la signature d une
// oscillation : on le calcule ici pour que l appelant n ait pas a le refaire.
for (let i = 0; i < lignes.length - 1; i++) {
const a = Date.parse(lignes[i].quand.replace(' ', 'T'));
const b = Date.parse(lignes[i + 1].quand.replace(' ', 'T'));
const objet = l => l.resume.split(':')[0];
if (objet(lignes[i]) === objet(lignes[i + 1])) lignes[i].secondes_depuis_precedente = Math.round((a - b) / 1000);
}
sortie({ transitions: lignes.length, lignes });
} else if (commande === 'mesures') {
const [cles, debut, fin = 'now()'] = args;
const liste = cles.split(',').map(c => ALIAS[c.trim()] || c.trim());
const borne = t => t === 'now()' ? 'now()' : `'${t}'::timestamp at time zone 'Europe/Paris'`;
const r = sql(`select to_char(ts at time zone 'Europe/Paris','HH24:MI:SS'), cle, round(valeur::numeric,1)
from mesure where cle in (${liste.map(c => `'${c}'`).join(',')})
and ts >= ${borne(debut)} and ts <= ${borne(fin)} order by ts limit 400`);
sortie({ points: r.length, lignes: r.map(([h, cle, v]) => ({ h, cle: cle.replace(/^(sensor|number)\.(victron_|gx_device_)?/, ''), v: Number(v) })) });
} else if (commande === 'decisions') {
const [wid, hd, hf, date = new Date().toISOString().slice(0, 10)] = args;
const { url, key } = cleN8n();
const r = await fetch(`${url}/api/v1/executions?workflowId=${wid}&limit=60`, { headers: { 'X-N8N-API-KEY': key } });
const liste = (await r.json()).data.filter(x => {
const hhmm = new Date(x.startedAt).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris', hour: '2-digit', minute: '2-digit' });
return x.startedAt.startsWith(date) && hhmm >= hd && hhmm <= hf;
});
const out = [];
for (const x of liste.slice(0, 12)) {
const d = await (await fetch(`${url}/api/v1/executions/${x.id}?includeData=true`, { headers: { 'X-N8N-API-KEY': key } })).json();
const run = d?.data?.resultData?.runData || {};
const heure = new Date(x.startedAt).toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris' });
const noeuds = {};
for (const [nom, v] of Object.entries(run)) {
const j = v?.[0]?.data?.main?.[0]?.[0]?.json;
if (!j) continue;
const garde = {};
for (const [k, val] of Object.entries(j)) {
if (['decision', 'computed', 'context', 'nightEvcsPreview'].includes(k) && typeof val === 'object') {
garde[k] = Object.fromEntries(Object.entries(val).filter(([, x]) => typeof x !== 'object').slice(0, 14));
}
}
if (Object.keys(garde).length) noeuds[nom] = garde;
}
out.push({ heure, statut: x.status, noeuds });
}
sortie({ executions: out.length, out });
} else if (commande === 'ecrivains') {
const [entite] = args;
const c = JSON.parse(fs.readFileSync('docs/mesures/carte-decisions.json', 'utf8'));
const d = c.carte[entite];
if (!d) { sortie({ erreur: 'entite inconnue de la carte', connues: Object.keys(c.carte) }); process.exit(0); }
const actifs = d.ecrivains.filter(e => !e.desactive);
sortie({
entite, libelle: d.libelle, proprietaire_declare: d.proprietaire, note: d.note,
ecrivains: actifs,
hors_proprietaire: actifs.filter(e => e.nom !== d.proprietaire).map(e => `${e.famille}:${e.nom}`),
desactives: d.ecrivains.filter(e => e.desactive).map(e => e.nom),
carte_generee: c.genere,
});
} else if (commande === 'bilan') {
const [h = '6'] = args;
const r = sql(`select to_char(ts at time zone 'Europe/Paris','HH24:MI'), statut, round(soc_pct),
round(soc_requis_pct::numeric,1), round(energie_a_couvrir_kwh::numeric,2), heure_croisement, reseau_necessaire
from bilan where ts > now() - interval '${Number(h)} hours'
and extract(minute from ts at time zone 'Europe/Paris')::int % 30 = 0 order by ts desc limit 40`);
sortie({ lignes: r.map(([h2, statut, soc, requis, couvrir, croisement, reseau]) =>
({ h: h2, statut, soc: Number(soc), soc_requis: Number(requis), a_couvrir_kwh: Number(couvrir), croisement, reseau_necessaire: reseau === 't' })) });
} else if (commande === 'cherche') {
const [motif] = args;
const trouves = [];
const fichiers = ['automations.yaml', 'scripts.yaml',
...(fs.existsSync(path.join(HA, 'packages')) ? fs.readdirSync(path.join(HA, 'packages')).filter(f => f.endsWith('.yaml')).map(f => `packages/${f}`) : [])];
for (const f of fichiers) {
const p = path.join(HA, f);
if (!fs.existsSync(p)) continue;
const lignes = fs.readFileSync(p, 'utf8').split('\n');
lignes.forEach((l, i) => {
if (l.includes(motif)) trouves.push({ fichier: f, ligne: i + 1, texte: l.trim().slice(0, 160), commente: l.trim().startsWith('#') });
});
}
sortie({ motif, occurrences: trouves.length, trouves: trouves.slice(0, 40) });
} else {
sortie({ erreur: `commande inconnue: ${commande}`, aide: 'node tools/enquete.mjs outils' });
}

File diff suppressed because one or more lines are too long