#!/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(/[\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.'}`);