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:
File diff suppressed because it is too large
Load Diff
1133
docs/mesures/campagne-conseiller-2026-09-12.json
Normal file
1133
docs/mesures/campagne-conseiller-2026-09-12.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
161
tools/enquete.mjs
Normal 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
Reference in New Issue
Block a user