Essai du 12/09 : avec la seule liste des ecrivains, le conseiller voyait le conflit mais accusait les deux workflows n8n — l intrus etait une automatisation Home Assistant. La carte sait lire QUI PEUT ecrire, elle ne peut pas deviner QUI DEVRAIT : le proprietaire est une decision humaine, desormais declaree et commentee dans l outil. Resultat : 3 ecrivains hors proprietaire sur le contacteur reseau, 1 sur le courant de la borne, 1 sur le verrou. Injectee dans le briefing entre bornes explicites, regeneree par --injecte. Le conseiller enonce maintenant la bonne forme (« contradiction entre le proprietaire et un autre ecrivain ») mais ne nomme toujours pas lequel : il lui manque l ATTRIBUTION — rien ne relie une bascule a son auteur. Le contexte Home Assistant distingue un appel de service d une automatisation ; c est la prochaine piste. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GwpFpy2aQ2rJok6tNQSbVj
210 lines
11 KiB
JavaScript
210 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
||
// CARTE DES DECISIONS — qui ecrit sur quoi, et avec quels seuils figes.
|
||
//
|
||
// Ce que le 12/09 a montre : cinq automates ecrivent sur le contacteur reseau,
|
||
// chacun localement correct, aucun ne voit les autres, et personne n avait la
|
||
// liste. Une automatisation Home Assistant coupait a 35 % de SOC pendant que le
|
||
// bilan energetique demandait 42,8 % — le conflit a dure sept mois sans etre vu.
|
||
//
|
||
// Cette carte est GENEREE depuis les sources, jamais tenue a la main : une carte
|
||
// maintenue a la main pourrit, et c est precisement ce qu on veut eviter.
|
||
//
|
||
// node tools/carte-decisions.mjs carte + ecart depuis la derniere fois
|
||
// node tools/carte-decisions.mjs --json sortie machine (entree du conseiller)
|
||
import fs from 'fs';
|
||
import os from 'os';
|
||
import path from 'path';
|
||
|
||
const HA = '/mnt/ARCH3/docker/HA';
|
||
const INSTANTANE = 'docs/mesures/carte-decisions.json';
|
||
|
||
// Actionneurs dont une ecriture concurrente a une consequence physique.
|
||
// ⚠️ LE PROPRIETAIRE EST UNE DECISION HUMAINE, PAS UNE DEDUCTION.
|
||
// La carte sait lire QUI PEUT ecrire ; elle ne peut pas deviner QUI DEVRAIT.
|
||
// Sans cette declaration, le conseiller voit bien le conflit mais designe le
|
||
// mauvais coupable — essai du 12/09 : il a accuse les deux workflows n8n alors
|
||
// que l intrus etait une automatisation Home Assistant.
|
||
// Un ecrivain hors de cette liste n est pas forcement fautif : il est A
|
||
// EXPLIQUER. C est precisement ce qui manquait pendant sept mois.
|
||
const ACTIONNEURS = [
|
||
['switch.edf_general_ok_switch', 'Contacteur reseau EDF',
|
||
'Victron - Battery / Grid Hysteresis Fix V1',
|
||
'seul cet automate ouvre et ferme le contacteur ; le controleur EVCS le DEMANDE via le helper de reprise nuit, les scripts sont des interventions manuelles assumees'],
|
||
['number.gx_device_ac_power_setpoint', 'Consigne de puissance reseau',
|
||
'HA Snapshot EVCS Surplus Controller V2.6',
|
||
'depuis le 01/09 : fermer le contacteur ne suffit pas, il faut demander la puissance'],
|
||
['switch.victron_evcs_charging', 'Charge de la borne',
|
||
'HA Snapshot EVCS Surplus Controller V2.6', 'proprietaire unique'],
|
||
['number.victron_evcs_set_current_command', 'Courant de la borne',
|
||
'HA Snapshot EVCS Surplus Controller V2.6',
|
||
'les scripts BOOST sont des interventions manuelles, reecrites au cycle suivant'],
|
||
['input_boolean.victron_grid_hysteresis_latch', 'Verrou hysteresis',
|
||
'Victron - Battery / Grid Hysteresis Fix V1', 'les scripts d urgence le forcent volontairement'],
|
||
['input_boolean.victron_evcs_night_grid_takeover_needed', 'Reprise reseau nuit',
|
||
'HA Snapshot EVCS Surplus Controller V2.6', 'proprietaire unique'],
|
||
['number.gx_device_ess_min_soc_limit', 'SOC minimal de l ESS',
|
||
null, 'aucun automate ne devrait y toucher — reglage GX'],
|
||
];
|
||
|
||
function cleApi() {
|
||
const p = path.join(os.homedir(), '.config/opencode/opencode.json');
|
||
const c = JSON.parse(fs.readFileSync(p, 'utf8'));
|
||
const e = c?.mcp?.['n8n-mcp']?.environment || {};
|
||
return { url: (e.N8N_API_URL || '').replace(/\/api\/v1\/?$/, ''), key: e.N8N_API_KEY };
|
||
}
|
||
|
||
// Une ECRITURE, pas une lecture : l entite apparait pres d un appel de service.
|
||
function ecrit(texte, entite) {
|
||
const i = texte.indexOf(entite);
|
||
if (i === -1) return null;
|
||
const services = [];
|
||
let p = 0;
|
||
while (true) {
|
||
const j = texte.indexOf(entite, p);
|
||
if (j === -1) break;
|
||
const autour = texte.slice(Math.max(0, j - 400), j + 400);
|
||
for (const m of autour.matchAll(/(turn_on|turn_off|set_value|toggle|set_current)/g)) services.push(m[1]);
|
||
p = j + 1;
|
||
}
|
||
return services.length ? [...new Set(services)] : null;
|
||
}
|
||
|
||
// Seuils numeriques compares a un SOC : la famille de valeurs qui a produit
|
||
// l incident. On les remonte tous, avec leur emplacement.
|
||
function seuilsSoc(texte, source) {
|
||
const trouves = [];
|
||
const motifs = [
|
||
/soc\w*\s*(?:<=?|>=?|===?)\s*(\d{1,3})(?:\D|$)/gi,
|
||
/(\d{1,3})\s*(?:<=?|>=?)\s*\w*soc/gi,
|
||
/\babove:\s*(\d{1,3})\b/gi,
|
||
/\bbelow:\s*(\d{1,3})\b/gi,
|
||
];
|
||
for (const re of motifs) {
|
||
for (const m of texte.matchAll(re)) {
|
||
const v = Number(m[1]);
|
||
if (v >= 5 && v <= 100) trouves.push({ valeur: v, extrait: m[0].replace(/\s+/g, ' ').slice(0, 60), source });
|
||
}
|
||
}
|
||
return trouves;
|
||
}
|
||
|
||
const sources = [];
|
||
|
||
// ── n8n ───────────────────────────────────────────────────────────────
|
||
const { url, key } = cleApi();
|
||
const r = await fetch(`${url}/api/v1/workflows?limit=100&active=true`, { headers: { 'X-N8N-API-KEY': key } });
|
||
for (const w of (await r.json()).data) {
|
||
sources.push({ famille: 'n8n', nom: w.name, id: w.id, texte: JSON.stringify(w) });
|
||
}
|
||
|
||
// ── Home Assistant ────────────────────────────────────────────────────
|
||
for (const f of ['automations.yaml', 'scripts.yaml']) {
|
||
const p = path.join(HA, f);
|
||
if (fs.existsSync(p)) sources.push({ famille: 'HA', nom: f, texte: fs.readFileSync(p, 'utf8') });
|
||
}
|
||
const paquets = path.join(HA, 'packages');
|
||
if (fs.existsSync(paquets)) {
|
||
for (const f of fs.readdirSync(paquets).filter(x => x.endsWith('.yaml'))) {
|
||
sources.push({ famille: 'HA', nom: `packages/${f}`, texte: fs.readFileSync(path.join(paquets, f), 'utf8') });
|
||
}
|
||
}
|
||
|
||
// ── Carte ─────────────────────────────────────────────────────────────
|
||
const carte = {};
|
||
for (const [entite, libelle, proprietaire, note] of ACTIONNEURS) {
|
||
carte[entite] = { libelle, proprietaire, note, ecrivains: [], lecteurs: [] };
|
||
for (const s of sources) {
|
||
if (!s.texte.includes(entite)) continue;
|
||
const services = ecrit(s.texte, entite);
|
||
// Un commentaire de desactivation ne compte pas comme un ecrivain.
|
||
const desactive = /#\s*(DESACTIVEE|DISABLED)/i.test(s.texte) && s.famille === 'HA';
|
||
if (services) carte[entite].ecrivains.push({ famille: s.famille, nom: s.nom, id: s.id, services, desactive });
|
||
else carte[entite].lecteurs.push(`${s.famille}:${s.nom}`);
|
||
}
|
||
}
|
||
|
||
const seuils = [];
|
||
for (const s of sources) seuils.push(...seuilsSoc(s.texte, `${s.famille}:${s.nom}`));
|
||
const parValeur = {};
|
||
for (const t of seuils) (parValeur[t.valeur] ||= []).push(t);
|
||
|
||
const resultat = { genere: new Date().toISOString(), carte, seuilsSoc: parValeur };
|
||
|
||
if (process.argv.includes('--json')) {
|
||
console.log(JSON.stringify(resultat, null, 2));
|
||
} else {
|
||
console.log('═══ QUI ECRIT SUR QUOI ═══\n');
|
||
for (const [entite, d] of Object.entries(carte)) {
|
||
const actifs = d.ecrivains.filter(e => !e.desactive);
|
||
console.log(`${d.libelle}`);
|
||
console.log(` ${entite}`);
|
||
if (!actifs.length) console.log(' (aucun ecrivain detecte)');
|
||
for (const e of actifs) {
|
||
console.log(` ✎ ${e.famille.padEnd(4)} ${e.nom}${e.id ? ` (${e.id})` : ''} [${e.services.join(', ')}]`);
|
||
}
|
||
for (const e of d.ecrivains.filter(x => x.desactive)) {
|
||
console.log(` ⊘ ${e.famille.padEnd(4)} ${e.nom} — desactive`);
|
||
}
|
||
console.log(` proprietaire declare : ${d.proprietaire || 'aucun'}`);
|
||
const intrus = actifs.filter(e => e.nom !== d.proprietaire);
|
||
if (intrus.length) console.log(` ⚠️ ${intrus.length} ECRIVAIN(S) HORS PROPRIETAIRE : ${intrus.map(e => e.nom).join(', ')}`);
|
||
console.log(` ${d.lecteurs.length} lecteur(s)\n`);
|
||
}
|
||
console.log('═══ SEUILS DE SOC FIGES ═══\n');
|
||
for (const v of Object.keys(parValeur).map(Number).sort((a, b) => a - b)) {
|
||
const t = parValeur[v];
|
||
const oú = [...new Set(t.map(x => x.source))];
|
||
console.log(` ${String(v).padStart(3)} % ×${String(t.length).padStart(2)} ${oú.slice(0, 3).join(', ')}${oú.length > 3 ? ` +${oú.length - 3}` : ''}`);
|
||
}
|
||
}
|
||
|
||
// ── Ecart depuis la derniere fois : c est ca, la trace des evolutions ──
|
||
if (!process.argv.includes('--json')) {
|
||
if (fs.existsSync(INSTANTANE)) {
|
||
const av = JSON.parse(fs.readFileSync(INSTANTANE, 'utf8'));
|
||
const cle = c => Object.entries(c.carte).flatMap(([e, d]) =>
|
||
d.ecrivains.filter(x => !x.desactive).map(x => `${e} <- ${x.famille}:${x.nom}`));
|
||
const avant = new Set(cle(av)), apres = new Set(cle(resultat));
|
||
const nouveaux = [...apres].filter(x => !avant.has(x));
|
||
const partis = [...avant].filter(x => !apres.has(x));
|
||
console.log(`\n═══ ECART DEPUIS ${av.genere.slice(0, 16).replace('T', ' ')} ═══`);
|
||
if (!nouveaux.length && !partis.length) console.log(' aucun changement d ecrivain');
|
||
for (const x of nouveaux) console.log(` + ${x}`);
|
||
for (const x of partis) console.log(` − ${x}`);
|
||
} else {
|
||
console.log('\n(premier instantane — l ecart sera affiche aux executions suivantes)');
|
||
}
|
||
}
|
||
// ── Injection dans le briefing du conseiller ──────────────────────────
|
||
// La carte est STRUCTURELLE : elle change quand on ajoute un automate, pas
|
||
// toutes les 15 minutes. Elle a donc sa place dans le prompt, regeneree ici
|
||
// plutot que transmise a chaque cycle. Bornes explicites pour qu une relecture
|
||
// voie tout de suite ce qui est genere et ce qui est ecrit a la main.
|
||
if (process.argv.includes('--injecte')) {
|
||
const P = 'src/advisor/BUILD_PROMPT.js';
|
||
const DEBUT = ' // <<< CARTE GENEREE — ne pas editer a la main, voir tools/carte-decisions.mjs';
|
||
const FIN = ' // CARTE GENEREE >>>';
|
||
const lignes = [" 'QUI ECRIT SUR QUOI (carte generee depuis les sources) :',"];
|
||
for (const [entite, d] of Object.entries(carte)) {
|
||
const actifs = d.ecrivains.filter(e => !e.desactive);
|
||
if (actifs.length < 2) continue;
|
||
const court = n => n.replace(/\.yaml$/, '').replace(/^(HA Snapshot |Victron - )/, '');
|
||
const intrus = actifs.filter(e => e.nom !== d.proprietaire).map(e => court(e.nom));
|
||
lignes.push(` '- ${d.libelle} : proprietaire = ${court(d.proprietaire || 'aucun')}${intrus.length ? `, AUTRES ECRIVAINS : ${intrus.join(', ')}` : ''}',`);
|
||
}
|
||
const vals = Object.keys(parValeur).map(Number).sort((a, b) => a - b);
|
||
lignes.push(` 'SEUILS DE SOC FIGES dans le code : ${vals.join(', ')} % — chacun peut contredire le bilan.',`);
|
||
lignes.push(" 'Quand un actionneur bascule de facon incoherente, le coupable le plus probable',");
|
||
lignes.push(" 'est un ecrivain AUTRE QUE SON PROPRIETAIRE. Nomme-le.',");
|
||
let src = fs.readFileSync(P, 'utf8');
|
||
const bloc = [DEBUT, ...lignes, FIN].join('\n');
|
||
src = src.includes(DEBUT)
|
||
? src.slice(0, src.indexOf(DEBUT)) + bloc + src.slice(src.indexOf(FIN) + FIN.length)
|
||
: src.replace(" 'STRATEGIES POSSIBLES :',", bloc + "\n '',\n 'STRATEGIES POSSIBLES :',");
|
||
fs.writeFileSync(P, src);
|
||
console.log(`\ncarte injectee dans ${P} (${lignes.length} lignes)`);
|
||
}
|
||
|
||
fs.mkdirSync(path.dirname(INSTANTANE), { recursive: true });
|
||
fs.writeFileSync(INSTANTANE, JSON.stringify(resultat, null, 2));
|