- Fiche agent maison (AGENTS.md) : prevision de nuit relayee par script, import
EDF, regle JEEP midi, saisie vehicule/niveau/depart, concierge qui relaie
« ✅ Noté » ou « Rien n'est noté », groupe A deplace (28 649 -> 25 831 car.).
- Nouvelles references : reference-concierge.md, reference-workspace.md.
- Scripts : rapport-solaire.js, prevision-nuit.js, airtria-ha-vehicule.js,
concierge-live.js (answer ne sort plus jamais vide, mode CONCIERGE_SIMULATION).
- Courrier Orange / AIRTRIA / Gmail : audits, regles, menage, recents.
- Bancs EVCS tests-pv (G5 accepte « anomal »).
- .gitignore : liste blanche etendue aux nouveaux scripts et bancs.
130 lines
8.3 KiB
JavaScript
130 lines
8.3 KiB
JavaScript
#!/usr/bin/env node
|
|
// e2e-attributs.mjs — banc BOUT EN BOUT : capteur -> airtria-ha-read.js -> agent.
|
|
//
|
|
// Complète campagne-pv.mjs, qui injecte des snapshots directement dans le contexte :
|
|
// il teste le RAISONNEMENT de l'agent mais ne traverse PAS l'outil de lecture, donc
|
|
// un défaut de l'outil lui est invisible (le 25/09 le banc était 15/15 alors que
|
|
// l'agent ne voyait aucun attribut en réel — trouvé à la main par le proprio).
|
|
//
|
|
// Ici on appelle le VRAI outil sur les VRAIES entités live et on vérifie qu'un fait
|
|
// porté UNIQUEMENT par un ATTRIBUT (module_le_plus_sensible, raison/source contrôleur,
|
|
// statut) remonte : (1) dans la sortie de l'outil — sinon l'agent ne PEUT pas le
|
|
// relayer, c'est exactement le bug ; (2) dans la réponse du modèle. L'attendu est lu
|
|
// AU MOMENT DU RUN via l'API HA : on note la CONCORDANCE, jamais une valeur figée.
|
|
//
|
|
// Usage : node tests-pv/e2e-attributs.mjs [passages=3]
|
|
import fs from 'node:fs';
|
|
import { execFileSync } from 'node:child_process';
|
|
|
|
const PASS = Number(process.argv.find(a => /^\d+$/.test(a)) || 3);
|
|
const WS = '/mnt/ARCH3/docker/openclaw-data/workspace';
|
|
const HA = process.env.HA_BASE || 'http://10.10.10.17:8123';
|
|
const TOKEN = fs.readFileSync(`${WS}/.ha-token`, 'utf8').trim();
|
|
const SPARK = process.env.SPARK_URL || 'http://10.10.10.8:8000/v1/chat/completions';
|
|
|
|
const agents = fs.readFileSync(`${WS}/AGENTS.md`, 'utf8');
|
|
const i = agents.indexOf('## Repères énergie');
|
|
let j = agents.indexOf('\n## ', i + 1); if (j < 0) j = agents.length;
|
|
const FICHE = agents.slice(i, j);
|
|
const SYS = `Tu es l'assistant vocal de la maison de Thierry. On te donne la sortie BRUTE de l'outil de lecture Home Assistant (airtria-ha-read.js) puis une question. Réponds en UNE phrase, en te fondant UNIQUEMENT sur cette sortie et sur ces repères. N'invente rien.
|
|
|
|
${FICHE}`;
|
|
|
|
const sa = s => String(s ?? '').normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase();
|
|
const mots = s => String(s || '').normalize('NFD').replace(/\p{Diacritic}/gu, '')
|
|
.split(/[^A-Za-z0-9]+/).filter(w => w.length > 4);
|
|
|
|
async function haState(entity) {
|
|
const r = await fetch(`${HA}/api/states/${entity}`, { headers: { Authorization: `Bearer ${TOKEN}` } });
|
|
if (!r.ok) throw new Error(`${entity} HTTP ${r.status}`);
|
|
return r.json();
|
|
}
|
|
const tool = entity => execFileSync('node', ['airtria-ha-read.js', entity], { cwd: WS }).toString();
|
|
async function ask(user) {
|
|
const r = await fetch(SPARK, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ model: 'qwen3-coder', temperature: 0.1, max_tokens: 300,
|
|
messages: [{ role: 'system', content: SYS }, { role: 'user', content: user }] }),
|
|
});
|
|
const jj = await r.json();
|
|
return (jj?.choices?.[0]?.message?.content ?? '').replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
|
|
}
|
|
|
|
// Chaque cas calcule ses tokens attendus À PARTIR de l'état live (attendus = OU).
|
|
const CAS = [
|
|
{ id: 'E1-cellule-module', entity: 'sensor.evcs_cellules',
|
|
question: 'Quelle est la cellule ou le module le plus sensible de la batterie en ce moment ?',
|
|
verite: a => a.statut === 'frais'
|
|
? { attendus: [a.module_le_plus_sensible, `module ${a.repere_le_plus_sensible}`, String(a.repere_le_plus_sensible)], interdits: [] }
|
|
: { attendus: ['indisponible', 'muet', 'perim', 'ne peux'], interdits: [a.module_le_plus_sensible].filter(Boolean) } },
|
|
{ id: 'E2-charge-source', entity: 'sensor.evcs_charge_solaire',
|
|
question: 'La voiture charge-t-elle en ce moment, et pour quelle raison ?',
|
|
verite: (a, st) => {
|
|
const base = [String(st)]; // l'état est TOUJOURS dans la sortie de l'outil (pour toolOk)
|
|
if (st === 'non_branchee') return { attendus: [...base, 'pas branch', 'non branch', 'ne charge pas', 'branchez'], interdits: [] };
|
|
const m = [...mots(a.source_controleur), ...mots(a.raison_controleur)];
|
|
if (st === 'en_attente') return { attendus: [...base, ...m, 'attente', 'controleur'], interdits: [] };
|
|
return { attendus: [...base, ...m], interdits: [] }; } },
|
|
{ id: 'E3-production-statut', entity: 'sensor.evcs_production_jour_kwh',
|
|
question: 'Quelle est la production solaire du jour ?',
|
|
verite: (a, st) => { let muets = a.chargeurs_muets;
|
|
if (typeof muets === 'string') { try { muets = JSON.parse(muets); } catch { muets = []; } }
|
|
if (!Array.isArray(muets)) muets = [];
|
|
return a.statut === 'partiel'
|
|
? { attendus: ['au moins', ...muets], interdits: [] }
|
|
: { attendus: [String(st), String(st).replace('.', ',')], interdits: [] }; } },
|
|
{ id: 'E5-cellule-repere', entity: 'sensor.evcs_cellules',
|
|
question: 'Quel est le repère du module le plus sensible de la batterie ?',
|
|
verite: a => a.statut === 'frais'
|
|
? { attendus: [`module ${a.repere_le_plus_sensible}`, String(a.repere_le_plus_sensible), 'repere'], interdits: [] }
|
|
: { attendus: ['indisponible', 'muet', 'perim'], interdits: [] } },
|
|
{ id: 'E8-import-jour', entity: 'sensor.evcs_import_jour_kwh',
|
|
question: 'Combien ai-je importé du réseau aujourd\'hui ?',
|
|
verite: (a, st) => (st == null || st === 'indisponible' || st === 'unavailable')
|
|
? { attendus: ['indisponible', 'ne peux', 'pas de', 'inconnu'], interdits: [] }
|
|
: { attendus: [String(st), String(st).replace('.', ',')], interdits: (a.victron_kwh != null && String(a.victron_kwh) !== String(st)) ? [String(a.victron_kwh), String(a.victron_kwh).replace('.', ',')] : [] } },
|
|
{ id: 'E7-reseau-etat', entity: 'sensor.evcs_reseau',
|
|
question: 'Le réseau EDF est-il raccordé en ce moment, depuis quand ?',
|
|
verite: (a, st) => st === 'non_raccorde'
|
|
? { attendus: [String(a.depuis_heure), 'non raccord', 'coupe', 'debranch', 'pas une panne', String(st)], interdits: [] }
|
|
: { attendus: [String(st), ...(a.explication ? [String(a.explication).split(/[^A-Za-z0-9]+/).filter(w => w.length > 5)[0] || ''] : [])], interdits: [] } },
|
|
{ id: 'E6-import-reseau', entity: 'sensor.evcs_bilan_nuit',
|
|
question: 'Combien ai-je importé du réseau cette nuit ?',
|
|
verite: a => { const v = a.reseau_importe_kwh;
|
|
return v != null ? { attendus: [String(v), String(v).replace('.', ',')], interdits: [] } : { attendus: ['0'], interdits: [] }; } },
|
|
{ id: 'E4-bilan-nuit-vehicule', entity: 'sensor.evcs_bilan_nuit',
|
|
question: 'Combien la voiture a-t-elle pris pendant la nuit ?',
|
|
verite: (a, st) => { // l'API HA peut renvoyer vehicules en tableau OU en chaîne JSON
|
|
let v = a.vehicules;
|
|
if (typeof v === 'string') { try { v = JSON.parse(v); } catch { v = []; } }
|
|
if (!Array.isArray(v)) v = [];
|
|
// nuit non terminée ou aucune charge : l'agent doit le dire, pas sortir un total
|
|
if (st === 'en_cours' || v.length === 0)
|
|
return { attendus: ['en_cours', '0', 'aucun', 'rien', 'pas de charge', 'pas finie', 'pas terminee', 'en cours', 'pas encore', 'nuit continue'], interdits: [] };
|
|
const kwh = v[0].energie_kwh;
|
|
return { attendus: [String(kwh), String(kwh).replace('.', ',')], interdits: [] }; } },
|
|
];
|
|
|
|
let toolFails = 0;
|
|
for (const c of CAS) {
|
|
const j = await haState(c.entity);
|
|
const out = tool(c.entity);
|
|
const { attendus, interdits } = c.verite(j.attributes || {}, j.state);
|
|
const att = attendus.filter(Boolean).map(sa);
|
|
const outN = sa(out);
|
|
const toolOk = att.length ? att.some(t => outN.includes(t)) : true; // l'outil expose-t-il la vérité ?
|
|
if (!toolOk) toolFails++;
|
|
const juge = txt => { const t = sa(txt);
|
|
return (att.length ? att.some(x => t.includes(x)) : true) && !interdits.map(sa).some(x => x && t.includes(x)); };
|
|
const reps = [];
|
|
for (let k = 0; k < PASS; k++) reps.push(await ask(`Sortie de l'outil :\n${out}\nQuestion : ${c.question}`));
|
|
const relais = reps.filter(juge).length;
|
|
|
|
console.log(`\n═══ ${c.id} (${c.entity})`);
|
|
console.log(` vérité live : attendus=[${attendus.filter(Boolean).join(' | ')}]${interdits.length ? ` interdits=[${interdits.join('|')}]` : ''}`);
|
|
console.log(` OUTIL expose la vérité : ${toolOk ? 'OUI' : "NON ← l'agent ne PEUT pas relayer (défaut de l'outil de lecture)"}`);
|
|
reps.forEach(r => console.log(` ${juge(r) ? '✔' : '✘'} ${r.slice(0, 120)}`));
|
|
console.log(` → relais ${relais}/${PASS}`);
|
|
}
|
|
console.log(`\n${toolFails ? `⚠️ ${toolFails} entité(s) dont l'outil n'expose pas la vérité — c'est le trou qu'on voulait attraper.` : '✓ l\'outil expose bien tous les attributs testés.'}`);
|