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": {
|
"carte": {
|
||||||
"switch.edf_general_ok_switch": {
|
"switch.edf_general_ok_switch": {
|
||||||
"libelle": "Contacteur reseau EDF",
|
"libelle": "Contacteur reseau EDF",
|
||||||
|
|||||||
@@ -34,64 +34,30 @@ setText('input_text.evcs_advisor_last_run', stampParis());
|
|||||||
setText('input_text.evcs_advisor_model', modelUsed);
|
setText('input_text.evcs_advisor_model', modelUsed);
|
||||||
|
|
||||||
if (status === 'ok' && advice) {
|
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_constat', advice.constat);
|
||||||
setText('input_text.evcs_advisor_gravite', advice.gravite);
|
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_ce_qui_cloche', advice.ceQuiCloche || 'rien de signale');
|
||||||
|
setText('input_text.evcs_advisor_reason', `[${Math.round(advice.confidence * 100)}%] ${advice.piste || advice.constat}`);
|
||||||
|
|
||||||
// Recommandations
|
// Un observateur qui signale en permanence ne sert a rien : on compte le
|
||||||
setText('input_text.evcs_advisor_strategy', advice.strategy);
|
// VOLUME de signalements pour pouvoir le constater.
|
||||||
// Les champs d action ne sont publies que s ils sont exploitables : un
|
if (advice.gravite === 'aucune') {
|
||||||
// helper numerique ne doit jamais porter une valeur hors bornes.
|
setNumber('input_number.evcs_advisor_agree_count', context.agreeCount + 1);
|
||||||
if (advice.actionUtilisable) {
|
} else {
|
||||||
setNumber('input_number.evcs_advisor_night_target_soc', advice.nightTargetSoc);
|
setNumber('input_number.evcs_advisor_diverge_count', context.divergeCount + 1);
|
||||||
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) {
|
|
||||||
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', {
|
push('logbook', 'log', {
|
||||||
name: 'EVCS Advisor (ombre)',
|
name: 'EVCS Advisor (observateur)',
|
||||||
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}`,
|
message: `[${advice.gravite}] ${advice.constat}`
|
||||||
entity_id: 'input_text.evcs_advisor_strategy',
|
+ (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.',
|
'est un ecrivain AUTRE QUE SON PROPRIETAIRE. Nomme-le.',
|
||||||
// CARTE GENEREE >>>
|
// CARTE GENEREE >>>
|
||||||
'',
|
'',
|
||||||
'STRATEGIES POSSIBLES :',
|
'TU NE PILOTES RIEN ET TU NE CHOISIS AUCUNE STRATEGIE.',
|
||||||
'- auto_solar_plus : automatique, solaire prioritaire, reseau en heures creuses si besoin',
|
'La planification energetique est faite par le bilan, qui est calcule et mesure.',
|
||||||
'- auto_solar : solaire strict, jamais de reseau',
|
'Ton seul travail est de REGARDER LE SYSTEME SE COMPORTER et de dire ce qui cloche.',
|
||||||
'- 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.',
|
|
||||||
'',
|
'',
|
||||||
'Reponds UNIQUEMENT avec un objet JSON valide, sans texte autour, au format EXACT :',
|
'Reponds UNIQUEMENT avec un objet JSON valide, sans texte autour, au format EXACT :',
|
||||||
'{"constat":"ce que tu observes, factuel, max 200 caracteres",',
|
'{"constat":"ce que tu observes, factuel, max 200 caracteres",',
|
||||||
'"gravite":"aucune|faible|moyenne|haute",',
|
'"gravite":"aucune|faible|moyenne|haute",',
|
||||||
'"ce_qui_cloche":"la contradiction ou l incoherence, ou \'\' si rien",',
|
'"ce_qui_cloche":"la contradiction ou l incoherence, ou \'\' si rien",',
|
||||||
'"strategy":"auto_solar_plus|auto_solar|force_grid_now|force_charge_now|stop",',
|
'"piste":"ce qu il faudrait verifier ou corriger, ou \'\' si rien",',
|
||||||
'"night_target_soc":<entier 22-90>,"current_max_a":<entier 6-28>,',
|
'"confidence":<0.0-1.0>}',
|
||||||
'"ev_charge_tonight":"oui|non|sans_objet","confidence":<0.0-1.0>,',
|
|
||||||
'"reason":"une phrase courte en francais (max 200 caracteres)"}',
|
|
||||||
'',
|
'',
|
||||||
'gravite "aucune" quand tout est coherent — c est le cas le plus frequent et',
|
'gravite "aucune" quand tout est coherent — c est le cas le plus frequent et de',
|
||||||
'il ne faut pas inventer un probleme pour justifier une reponse.',
|
'loin. N invente pas un probleme pour justifier une reponse : un observateur qui',
|
||||||
|
'signale en permanence ne sert a rien.',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
function nombre(v, unite, defaut) {
|
function nombre(v, unite, defaut) {
|
||||||
|
|||||||
@@ -7,8 +7,6 @@
|
|||||||
const resp = $input.first().json;
|
const resp = $input.first().json;
|
||||||
const context = $('BUILD_PROMPT').first().json.context;
|
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) {
|
function fail(status, detail) {
|
||||||
return [{
|
return [{
|
||||||
@@ -52,36 +50,6 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Validation stricte du schéma (enums + bornes)
|
// 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) ───────────────────────────────────────────
|
// ── OBSERVATION (12/09/2026) ───────────────────────────────────────────
|
||||||
// La mission principale du conseiller n est plus de choisir une strategie
|
// 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
|
// 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();
|
const gravite = (obj.gravite ?? '').toString().trim().toLowerCase();
|
||||||
if (!GRAVITES.includes(gravite)) return fail('invalid', `gravite hors enum: ${obj.gravite}`);
|
if (!GRAVITES.includes(gravite)) return fail('invalid', `gravite hors enum: ${obj.gravite}`);
|
||||||
const ceQuiCloche = (obj.ce_qui_cloche ?? '').toString().trim();
|
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 [{
|
return [{
|
||||||
json: {
|
json: {
|
||||||
@@ -104,15 +78,8 @@ return [{
|
|||||||
constat: constat.slice(0, 200),
|
constat: constat.slice(0, 200),
|
||||||
gravite,
|
gravite,
|
||||||
ceQuiCloche: ceQuiCloche.slice(0, 200),
|
ceQuiCloche: ceQuiCloche.slice(0, 200),
|
||||||
strategy,
|
piste: piste.slice(0, 200),
|
||||||
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,
|
|
||||||
confidence: Math.round(confidence * 100) / 100,
|
confidence: Math.round(confidence * 100) / 100,
|
||||||
reason: reason.slice(0, 250),
|
|
||||||
},
|
},
|
||||||
modelUsed: (resp.model || 'inconnu').toString().slice(0, 60),
|
modelUsed: (resp.model || 'inconnu').toString().slice(0, 60),
|
||||||
context,
|
context,
|
||||||
|
|||||||
@@ -65,39 +65,24 @@ check('BUILD_PROMPT: kill-switch OFF → enabled=false', killSwitchOut.enabled =
|
|||||||
|
|
||||||
// ---------- VALIDATE_PARSE : 7 réponses LLM simulées ----------
|
// ---------- VALIDATE_PARSE : 7 réponses LLM simulées ----------
|
||||||
const ctxNode = { BUILD_PROMPT: promptOut };
|
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 = [
|
const cases = [
|
||||||
{
|
{
|
||||||
label: 'réponse valide → ok',
|
label: 'réponse valide → ok',
|
||||||
resp: { model: 'qwen3.6-35b', choices: [{ message: { content: validAdvice } }] },
|
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',
|
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\`\`\`` } }] },
|
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)',
|
label: 'confidence hors bornes → invalid',
|
||||||
resp: { model: 'x', choices: [{ message: { content: validAdvice.replace('auto_solar_plus', 'charge_maximale') } }] },
|
resp: { model: 'x', choices: [{ message: { content: validAdvice.replace('"confidence":0.85', '"confidence":7') } }] },
|
||||||
expect: o => o.status === 'invalid' && o.advice === null,
|
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',
|
label: 'texte sans JSON → invalid',
|
||||||
resp: { model: 'x', choices: [{ message: { content: 'Je recommande le mode solaire.' } }] },
|
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 ALLOWED = /^(input_text|input_number)\.evcs_advisor_/;
|
||||||
const violations = calls.filter(c => !(c.domain === 'logbook' || ALLOWED.test(c.data.entity_id || '')));
|
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: 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) ──────────
|
// ── LE COMPTEUR MESURE LE VOLUME DE SIGNALEMENTS (12/09/2026) ─────────
|
||||||
// Il comparait la STRATEGIE, qui vaut auto_solar_plus la quasi-totalite du
|
// Un observateur qui signale en permanence ne sert a rien. On ne peut pas
|
||||||
// temps : 517/23, soit 95,7 % — un score qui mesurait une constante. La cible,
|
// mesurer automatiquement s il a RAISON ; on peut mesurer s il crie au loup.
|
||||||
// seul champ cable, divergeait de 13 points du bilan sans etre comptee.
|
function compteur(json) {
|
||||||
function compteur(ctx) {
|
const v = run(VALIDATE_PARSE, { model: 'x', choices: [{ message: { content: json } }] }, ctxNode)[0].json;
|
||||||
const node = { BUILD_PROMPT: { ...promptOut, context: ctx } };
|
const out = run(BUILD_HA_CALLS, v, ctxNode).map(i => i.json);
|
||||||
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));
|
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;
|
const rienASignaler = compteur(validAdvice);
|
||||||
|
check('COMPTEUR: gravite aucune → compte comme calme', rienASignaler.calme && !rienASignaler.signale);
|
||||||
// L'agent dit 35 (le validAdvice des cas ci-dessus).
|
const signalement = compteur(validAdvice.replace('"gravite":"aucune"', '"gravite":"haute"'));
|
||||||
const proche = compteur({ ...base, balanceStatus: 'ok', balanceRequiredSoc: 33 });
|
check('COMPTEUR: gravite haute → compte comme signalement', signalement.signale && !signalement.calme);
|
||||||
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);
|
|
||||||
|
|
||||||
// ── OBSERVATION : le constat et la gravite sont obligatoires ───────────
|
// ── OBSERVATION : le constat et la gravite sont obligatoires ───────────
|
||||||
function valider(json) {
|
function valider(json) {
|
||||||
@@ -163,28 +138,16 @@ check('OBSERVATION: constat vide → REJET',
|
|||||||
check('OBSERVATION: gravite hors enum → REJET',
|
check('OBSERVATION: gravite hors enum → REJET',
|
||||||
valider(validAdvice.replace('"gravite":"aucune"', '"gravite":"catastrophique"')).status === 'invalid');
|
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);
|
const oscCalls = run(BUILD_HA_CALLS, osc, ctxNode).map(i => i.json);
|
||||||
check('OBSERVATION: constat, gravite et diagnostic publies',
|
check('OBSERVATION: constat, gravite et diagnostic publies',
|
||||||
['constat', 'gravite', 'ce_qui_cloche'].every(k => oscCalls.some(c => (c.data.entity_id || '').includes(`advisor_${k}`))));
|
['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_*',
|
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 || '')));
|
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);
|
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');
|
console.log(failures ? `\n${failures} ÉCHEC(S) — NE PAS POUSSER` : '\nADVISOR OK — tous les tests passent');
|
||||||
process.exit(failures ? 1 : 0);
|
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