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

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);