Les observations du conseiller cessent de s evaporer

Ses helpers sont ecrases toutes les quinze minutes : un signalement grave
emis a 3 h du matin avait disparu au reveil, et rien dans la memoire du
projet n en gardait trace. C est precisement ce que le proprietaire
demandait quand il a dit vouloir un outil pour garder une trace des
evolutions — voir un comportement idiot du systeme ne sert a rien si
personne ne peut le relire le lendemain.

Une table `observation`, alimentee par le service d historisation, clef sur
l horodatage du marqueur du conseiller : rejouer un cycle n invente rien et
un conseiller a l arret n ecrit pas la meme ligne toutes les minutes. La
confiance est extraite de `_reason` (« [NN%] la piste ») pour rester
interrogeable sans decouper une chaine en SQL.

`node tools/enquete.mjs observations [heures] [gravite]` ne montre par
defaut que ce qui a ete signale, et compte a part les cycles calmes : un
observateur qui fonctionne est silencieux la plupart du temps, et noyer les
signalements sous des centaines de « rien a dire » les rendrait invisibles.

Essai a blanc : observation=1, le service ecrirait bien sa ligne.

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-13 11:30:18 +02:00
parent e95ea15516
commit 2345b87d04
2 changed files with 78 additions and 3 deletions

View File

@@ -66,6 +66,7 @@ if (!commande || commande === 'outils') {
{ nom: 'decisions', usage: 'decisions <workflowId> <hhmm_debut> <hhmm_fin> [date]', quoi: 'ce qu un workflow n8n a decide, cycle par cycle' }, { 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: '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: 'bilan', usage: 'bilan [heures=6]', quoi: 'ce que le bilan energetique demandait' },
{ nom: 'observations', usage: 'observations [heures=24] [gravite]', quoi: 'ce que le conseiller a signale, et a quelle gravite' },
{ nom: 'cherche', usage: 'cherche <motif>', quoi: 'ou ce motif apparait dans les automatisations, scripts et paquets Home Assistant' }, { nom: 'cherche', usage: 'cherche <motif>', quoi: 'ou ce motif apparait dans les automatisations, scripts et paquets Home Assistant' },
], ],
}); });
@@ -144,6 +145,25 @@ if (!commande || commande === 'outils') {
and extract(minute from ts at time zone 'Europe/Paris')::int % 30 = 0 order by ts desc limit 40`); 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]) => 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' })) }); ({ h: h2, statut, soc: Number(soc), soc_requis: Number(requis), a_couvrir_kwh: Number(couvrir), croisement, reseau_necessaire: reseau === 't' })) });
} else if (commande === 'observations') {
// Par defaut on ne montre QUE ce qui a ete signale : un conseiller qui
// fonctionne est silencieux la plupart du temps, et noyer les signalements
// dans des centaines de « rien a dire » les rendrait invisibles.
const [h = '24', gravite] = args;
const filtre = gravite ? `and gravite = '${gravite.replace(/'/g, '')}'` : "and gravite <> 'aucune'";
const r = sql(`select to_char(ts at time zone 'Europe/Paris','DD/MM HH24:MI'), gravite,
constat, ce_qui_cloche, piste, round(soc_pct), statut
from observation where ts > now() - interval '${Number(h)} hours' ${filtre}
order by ts desc limit 40`);
const calmes = sql(`select count(*) from observation
where ts > now() - interval '${Number(h)} hours' and gravite = 'aucune'`);
sortie({
fenetre_heures: Number(h),
cycles_calmes: Number(calmes[0]?.[0] ?? 0),
signalements: r.length,
lignes: r.map(([q, g, c, cl, p, soc, st]) =>
({ quand: q, gravite: g, constat: c, ce_qui_cloche: cl, piste: p, soc: Number(soc), statut: st })),
});
} else if (commande === 'cherche') { } else if (commande === 'cherche') {
const [motif] = args; const [motif] = args;
const trouves = []; const trouves = [];

View File

@@ -263,6 +263,37 @@ async function ecrireBilan(sql, st) {
return rowCount; return rowCount;
} }
// Une observation par run du conseiller. La cle est l'horodatage de SON
// marqueur : rejouer un cycle n'invente rien, et un conseiller a l'arret
// n'ecrit pas la meme observation toutes les minutes.
async function ecrireObservation(sql, st) {
const marqueur = st.get('input_text.evcs_advisor_last_run');
if (!marqueur || !txt(marqueur.state)) return 0;
const g = (id) => txt(st.get(id)?.state);
// `_reason` porte « [NN%] la piste ». On separe pour que la confiance
// reste interrogeable sans avoir a decouper une chaine en SQL.
const reason = g('input_text.evcs_advisor_reason') || '';
const m = reason.match(/^\[(\d+)%\]\s*(.*)$/s);
const { rowCount } = await sql(
`INSERT INTO observation (ts, statut, gravite, constat, ce_qui_cloche,
piste, modele, soc_pct, brut)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
ON CONFLICT (ts) DO NOTHING`,
[
marqueur.last_updated,
g('input_text.evcs_advisor_status'),
g('input_text.evcs_advisor_gravite'),
g('input_text.evcs_advisor_constat'),
g('input_text.evcs_advisor_ce_qui_cloche'),
m ? m[2] : reason,
g('input_text.evcs_advisor_model'),
num(st.get('sensor.victron_battery_soc')?.state),
JSON.stringify({ confiance_pct: m ? Number(m[1]) : null, reason_brut: reason }),
]
);
return rowCount;
}
async function ecrireDecision(sql, st) { async function ecrireDecision(sql, st) {
const etat = st.get('input_text.victron_evcs_surplus_state'); const etat = st.get('input_text.victron_evcs_surplus_state');
if (!etat) return 0; if (!etat) return 0;
@@ -509,10 +540,32 @@ async function migrer(client) {
)`); )`);
await client.query( await client.query(
'CREATE INDEX IF NOT EXISTS cellule_module_ts_idx ON cellule (module, ts DESC)'); 'CREATE INDEX IF NOT EXISTS cellule_module_ts_idx ON cellule (module, ts DESC)');
// Ce que le CONSEILLER a observe. Ses helpers sont ecrases toutes les
// quinze minutes : sans cette table, un signalement grave emis a 3 h du
// matin a disparu au reveil. C'est precisement la trace que le proprietaire
// demandait — voir un comportement idiot ne sert a rien si personne ne peut
// le relire le lendemain.
await client.query(`
CREATE TABLE IF NOT EXISTS observation (
ts timestamptz PRIMARY KEY,
statut text,
gravite text,
constat text,
ce_qui_cloche text,
piste text,
modele text,
soc_pct double precision,
brut jsonb
)`);
// On interroge cette table par gravite bien plus souvent que par date :
// « montre-moi tous les signalements graves de la semaine ».
await client.query(
'CREATE INDEX IF NOT EXISTS observation_gravite_idx ON observation (gravite, ts DESC)');
// Le role de lecture doit voir la nouvelle table. S'il n'existe pas encore, // Le role de lecture doit voir la nouvelle table. S'il n'existe pas encore,
// on ne fait pas echouer le demarrage pour autant. // on ne fait pas echouer le demarrage pour autant.
await client.query(`DO $$ BEGIN await client.query(`DO $$ BEGIN
GRANT SELECT ON session_charge TO evcs_lecture; GRANT SELECT ON session_charge, cellule, observation TO evcs_lecture;
EXCEPTION WHEN undefined_object THEN NULL; END $$`); EXCEPTION WHEN undefined_object THEN NULL; END $$`);
} }
@@ -806,6 +859,7 @@ async function cycle(client) {
mesures: await ecrireMesures(sql, st), mesures: await ecrireMesures(sql, st),
bilan: await ecrireBilan(sql, st), bilan: await ecrireBilan(sql, st),
decision: await ecrireDecision(sql, st), decision: await ecrireDecision(sql, st),
observation: await ecrireObservation(sql, st),
prevision: await ecrirePrevision(sql, st), prevision: await ecrirePrevision(sql, st),
session: await ecrireSession(sql, client, st), session: await ecrireSession(sql, client, st),
evenement: await detecterEvenements(sql, st), evenement: await detecterEvenements(sql, st),
@@ -820,12 +874,13 @@ async function cycle(client) {
return 0; return 0;
}), }),
}; };
const total = r.mesures + r.bilan + r.decision + r.prevision + r.evenement + r.session + r.cellules + (r.rapport || 0); const total = r.mesures + r.bilan + r.decision + r.prevision + r.evenement + r.session + r.cellules + r.observation + (r.rapport || 0);
if (total > 0 || ONCE) { if (total > 0 || ONCE) {
console.log( console.log(
`${new Date().toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })} ` + `${new Date().toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })} ` +
`${DRY ? '[BLANC] ' : ''}mesures=${r.mesures} bilan=${r.bilan} ` + `${DRY ? '[BLANC] ' : ''}mesures=${r.mesures} bilan=${r.bilan} ` +
`decision=${r.decision} prevision=${r.prevision} session=${r.session} evenement=${r.evenement} cellules=${r.cellules}` `decision=${r.decision} prevision=${r.prevision} session=${r.session} evenement=${r.evenement} ` +
`observation=${r.observation} cellules=${r.cellules}`
); );
} }
} }