workspace: rapport solaire, prevision de nuit, concierge, courrier, fiche allegee

- 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.
This commit is contained in:
2026-09-30 11:32:44 +02:00
parent 973a736bc2
commit f121f0a196
37 changed files with 4030 additions and 328 deletions

View File

@@ -0,0 +1,70 @@
# tests-pv — banc de test « gammes PV » de l'agent vocal
Garde-fou de la section **« Repères énergie »** d'`AGENTS.md`. À rejouer à
CHAQUE modification de cette section : il vérifie que l'agent lit la bonne
source, discrimine bridage / vraie baisse / substitution, décide (charge
voiture, reste à capter) et reste propre sur des données abîmées (gel GX,
capteur `unavailable`).
## Lancer
node tests-pv/campagne-pv.mjs [passages=3] [--only=<id>]
- Extrait la section « Repères énergie » de la vraie `AGENTS.md` à l'exécution
(donc teste la fiche DÉPLOYÉE, pas une copie).
- Envoie des snapshots FIGÉS de capteurs au modèle Spark (`qwen3-coder`), puis
note sur N passages (>=3 : un run unique ne distingue pas 3/5 de 5/5).
- Écrit un journal `journal-<horodatage>.json` à côté du script.
Variables : `SPARK_URL` (défaut http://10.10.10.8:8000/v1/chat/completions),
`AGENTS_MD` (défaut ../AGENTS.md).
## Vérité-terrain
Snapshots et attendus = mesures RÉELLES fournies par le contrôleur EVCS
(PostgreSQL/InfluxDB). **Ne jamais inventer un attendu** — cf. le bug des
4,2 kWh (extrapolation prise pour du réalisé). Statut de chaque cas :
| Cas | Teste | Source |
|-----|-------|--------|
| G1 | production du jour = somme des 3 yield | réel |
| G2 | reste à produire = prévision restante (seule exception) | réel |
| G3 | bridage batterie pleine → lit `evcs_pv_diagnostic`=bridage_normal, ne rejuge pas | réel |
| G4 | vraie baisse → `evcs_pv_diagnostic`=ressource_limitee | réel |
| G5 | substitution → `evcs_pv_diagnostic`=substitution_edf, l'agent NE rejuge PAS | apprentissage (réel 27/08) |
| G6 | charge voiture : excédent négatif → non | réel |
| G7 | reste-t-il du solaire à capter | réel |
| G8 | gel GX en journée → ne pas citer une valeur figée | synthétique hygiène |
| G9 | yield `unavailable` → production partielle, ne pas sommer un trou | synthétique |
| G10 | prévision de production DEMAIN (≠ réalisé du jour) | réel |
| G11 | cellule la plus sensible → détail BMS `unavailable` (HA), le dire sans inventer | réel (HA cell down depuis 15/09) |
| G11b | cellule la plus sensible → identifier le plus grand déséquilibre | réel (relevé série EVCS) |
| G12 | charge voiture nuit : faut-il de l'EDF ? surplus vs besoin, jamais soustraire un SOC | réel |
Architecture (25/09/2026) : le JUGEMENT est sorti de la fiche vers des capteurs
calculés côté service EVCS — `sensor.evcs_pv_diagnostic` (état + explication) et
`sensor.evcs_cellules` (écart + module, ou statut perime/muet). L'agent LIT et
RELAIE, il ne discrimine plus. Fraîcheur via `sensor.evcs_historisation_heartbeat`.
Cela a GUÉRI la fragilité : G3, G4, G5, G13b = **7/7** sur 7 passages (avant, un ajout
anodin ailleurs faisait chuter G5 de 7/7 à 1/7). Dernier état (26/09) : réseau/EDF câblé — import du jour (evcs_import_jour_kwh, compteur EDF), import nuit (bilan_nuit.reseau_importe_kwh), état réseau (evcs_reseau : non_raccorde/mesure_muette/incoherent). Nouveaux cas 5/5. e2e E1–E8. Gmail déplacé en reference-gmail.md (garde-fous 4.6/4.7 gardés en fiche). Fiche 24 137 car (marge 5 863).
## Banc bout en bout — `e2e-attributs.mjs`
`campagne-pv.mjs` injecte des snapshots directement : il teste le raisonnement,
mais ne traverse pas l'outil de lecture `airtria-ha-read.js`. Un défaut de l'outil
lui est donc invisible — c'est ainsi qu'un banc à 15/15 a coexisté avec un agent
qui ne voyait aucun attribut en réel (le module de la cellule, trouvé à la main
par le propriétaire le 25/09).
`e2e-attributs.mjs` bouche ce trou : il appelle le VRAI outil sur les VRAIES
entités live et vérifie qu'un fait porté UNIQUEMENT par un attribut remonte
(1) dans la sortie de l'outil, (2) dans la réponse du modèle. L'attendu est lu au
moment du run via l'API HA (concordance, jamais une valeur figée).
node tests-pv/e2e-attributs.mjs [passages=3]
Cas : E1 module_le_plus_sensible (evcs_cellules), E2 source/raison contrôleur
(evcs_charge_solaire), E3 statut (evcs_production_jour_kwh). Si l'outil n'expose
pas la vérité, le banc l'affiche en clair — c'est le défaut qu'on veut attraper.

View File

@@ -0,0 +1,486 @@
#!/usr/bin/env node
// campagne-pv.mjs — Banc de test « gammes PV » pour l'agent vocal OpenClaw.
// Modèle : tools/campagne-conseiller.mjs (EVCS). Snapshot figé → prompt → Spark →
// notation sur N passages (>=3, cf. HANDOFF §63 : un seul run de 5 ne distingue
// pas 3/5 de 5/5). Ne teste PAS l'agent en direct sur Home Assistant : il injecte
// les valeurs de capteurs dans le prompt, donc reproductible et sans attendre
// qu'il fasse nuageux à midi.
//
// La fiche « Repères énergie » testée est EXTRAITE d'AGENTS.md à l'exécution :
// le banc valide donc la vraie fiche déployée, pas une copie.
//
// Snapshots + attendus = vérité-terrain fournie par EVCS (PostgreSQL/InfluxDB).
// Ne JAMAIS inventer un attendu : cf. le bug des 4,2 kWh.
//
// Usage : node campagne-pv.mjs [passages=3] [--only=<id>]
'use strict';
import fs from 'node:fs';
const PASSAGES = Number((process.argv.find(a => /^\d+$/.test(a))) || 3);
const ONLY = (process.argv.find(a => a.startsWith('--only=')) || '').slice(7);
const URL = process.env.SPARK_URL || 'http://10.10.10.8:8000/v1/chat/completions';
const AGENTS = process.env.AGENTS_MD || '/mnt/ARCH3/docker/openclaw-data/workspace/AGENTS.md';
// --- fiche "Repères énergie" extraite d'AGENTS.md (ce que l'agent vocal utilise) ---
const agents = fs.readFileSync(AGENTS, 'utf8');
const iFiche = agents.indexOf('## Repères énergie');
if (iFiche < 0) { console.error('Section "Repères énergie" introuvable dans AGENTS.md'); process.exit(2); }
let jFiche = agents.indexOf('\n## ', iFiche + 1);
if (jFiche < 0) jFiche = agents.length;
const FICHE = agents.slice(iFiche, jFiche);
const SYSTEME =
`Tu es l'assistant vocal de la maison de Thierry. On te donne l'état courant des
capteurs Home Assistant, puis une question. Réponds en UNE phrase, naturelle à
dire à voix haute, en te fondant UNIQUEMENT sur les capteurs fournis et sur ces
repères. Ne lance aucune commande, n'invente aucune valeur.
${FICHE}`;
const snapTexte = o => Object.entries(o).map(([k, v]) => `${k} = ${v}`).join('\n');
// --- GAMMES ---
// motsCles : liste de GROUPES ; chaque groupe doit avoir AU MOINS un terme présent (ET de OU).
// interdits : si l'un apparaît, la réponse est fausse (piège non évité).
// helper : les 7 capteurs de mode MPPT, tous à la même valeur
const modes = v => ({
'sensor.v1_mppt_operation_mode': v,
'sensor.airtria_mppt1_mppt_operation_mode': v,
'sensor.mppt_450_200_hq2449yr9pt_mppt_operation_mode': v,
'sensor.mppt_450_200_hq2449yr9pt_pv_tracker_solar_1_operation_mode': v,
'sensor.mppt_450_200_hq2449yr9pt_pv_tracker_solar_2_operation_mode': v,
'sensor.mppt_450_200_hq2449yr9pt_pv_tracker_solar_3_operation_mode': v,
'sensor.mppt_450_200_hq2449yr9pt_pv_tracker_solar_4_operation_mode': v,
});
const S_G3 = { // bridage midi 24/09 16:55 — réutilisé par G7
'sensor.victron_pv_power_system': '1346', 'sensor.victron_consumption_power_l1': '1170',
'sensor.victron_battery_soc': '97', 'sensor.victron_battery_current': '1',
'sensor.victron_grid_power_l1': '0', 'sensor.evcs_ac_power': '0',
'sensor.energy_production_today': '31.13', ...modes('voltage_current_limited'),
};
const CAS = [
{ // G1 — production du jour → sensor.evcs_production_jour_kwh (complet)
id: 'G1-production-du-jour',
question: "Quelle est la production solaire aujourd'hui ?",
s: {
'sensor.evcs_production_jour_kwh': '45.1',
'sensor.evcs_production_jour_kwh.statut': 'complet',
'sensor.evcs_production_jour_kwh.explication': 'Production reelle du jour : 45.1 kWh (somme des trois chargeurs).',
},
motsCles: [['45']],
interdits: ['30,2', '30.2'],
attendu: 'état 45.1 statut complet → « Production du jour = 45,1 kWh ».',
},
{ // G3 — bridage batterie pleine → LIT le diagnostic pré-calculé, ne rejuge pas
id: 'G3-bridage-batterie-pleine',
question: "Il fait plein soleil mais la production ne dépasse pas 1,3 kW. C'est une mauvaise journée solaire ?",
s: {
'sensor.evcs_pv_diagnostic': 'bridage_normal',
'sensor.evcs_pv_diagnostic.explication': '7 chargeur(s) se retiennent et le reseau n\'importe rien : bridage normal d\'une installation sans injection. De la ressource solaire reste disponible pour une charge de plus.',
'sensor.evcs_pv_diagnostic.part_reseau_pct': '0',
'sensor.evcs_pv_diagnostic.pv_w': '1346', 'sensor.evcs_pv_diagnostic.conso_w': '1170',
'sensor.evcs_pv_diagnostic.import_w': '0', 'sensor.evcs_pv_diagnostic.soc_pct': '97',
'sensor.evcs_pv_diagnostic.mppt_bridés': '7',
},
motsCles: [['brid', 'normal', 'pleine', 'preneur']],
interdits: ['mauvaise journ', 'faible ensoleill', 'peu de soleil', 'moins de soleil', 'nuage'],
attendu: 'état bridage_normal → relaie : pas une mauvaise journée. ÉCHEC si « mauvaise journée ».',
},
{ // G4 — vraie baisse → diagnostic ressource_limitee
id: 'G4-vraie-baisse',
question: "La production n'est qu'à 1,2 kW ce matin. Est-ce que les panneaux sont bridés ?",
s: {
'sensor.evcs_pv_diagnostic': 'ressource_limitee',
'sensor.evcs_pv_diagnostic.explication': 'Tous les chargeurs donnent leur maximum : la production est limitee par le soleil, rien n\'est gache.',
'sensor.evcs_pv_diagnostic.part_reseau_pct': '0',
'sensor.evcs_pv_diagnostic.pv_w': '1248', 'sensor.evcs_pv_diagnostic.conso_w': '402',
'sensor.evcs_pv_diagnostic.soc_pct': '69', 'sensor.evcs_pv_diagnostic.import_w': '0',
'sensor.evcs_pv_diagnostic.mppt_a_fond': '7',
},
motsCles: [['non', 'pas brid', 'ne sont pas', 'ressource', 'limit', 'a fond', 'mppt_active']],
interdits: [],
attendu: 'état ressource_limitee → non, pas de bridage, ressource réellement faible.',
},
{ // G6 — peut-on charger → RELAIE sensor.evcs_charge_solaire (raison contrôleur RÉELLE)
id: 'G6-charge-voiture-relais',
statut: 'RÉEL (live 01:19, raison contrôleur réelle)',
question: 'Peut-on charger la voiture sur le solaire maintenant ?',
s: {
'sensor.evcs_charge_solaire': 'en_attente',
'sensor.evcs_charge_solaire.explication': 'Voiture branchee mais pas en charge. Le controleur dit : Hors fenetre preferee sans source suffisante (source : Bloque).',
'sensor.evcs_charge_solaire.raison_controleur': 'Hors fenetre preferee sans source suffisante',
'sensor.evcs_charge_solaire.etat_controleur': 'blocked_outside_window',
'sensor.evcs_charge_solaire.excedent_w': '-915',
},
motsCles: [['controleur', 'fenetre', 'attente', 'pas en charge', 'branche']],
interdits: [],
attendu: 'relaie en_attente + raison du contrôleur (hors fenêtre). ÉCHEC si conclut « non car excédent négatif » de sa propre analyse.',
},
{ // Gnuit — assistance batterie nuit → NE JAMAIS dire solaire (bug capteur corrigé EVCS beed802)
id: 'Gnuit-charge-batterie-pas-solaire',
statut: 'RÉEL (état exact EVCS après correctif beed802)',
question: 'La voiture charge-t-elle sur le solaire en ce moment ?',
s: {
'sensor.evcs_charge_solaire': 'en_charge_batterie',
'sensor.evcs_charge_solaire.source_controleur': 'Assistance batterie nuit',
'sensor.evcs_charge_solaire.puissance_borne_w': '1198',
'sensor.evcs_charge_solaire.explication': 'La voiture charge (1198 W) sur la batterie de la maison (assistance de nuit) (source decidee par le controleur : Assistance batterie nuit).',
},
motsCles: [['batterie', 'assistance', 'maison', 'nuit']],
interdits: ['solaire'],
attendu: 'état en_charge_batterie → « sur la batterie maison (assistance nuit) », JAMAIS solaire. ÉCHEC si dit solaire.',
},
{ // G7 — reste à capter → evcs_pv_diagnostic.mppt_bridés > 0
id: 'G7-reste-solaire-a-capter',
question: 'Reste-t-il du solaire à capter en ce moment ?',
s: { 'sensor.evcs_pv_diagnostic': 'bridage_normal' },
motsCles: [['oui', 'reste', 'encore'], ['brid', 'refus', 'preneur', 'disponible', 'capter', 'ressource', 'controleur']],
interdits: [],
attendu: 'état bridage_normal → OUI, il reste du solaire (le contrôleur décide s\'il le prend).',
},
{ // G7b — nuit : mppt_bridés=0 → RIEN à capter (garde-fou 'off')
id: 'G7b-nuit-rien-a-capter',
question: 'Reste-t-il du solaire à capter en ce moment ?',
s: { 'sensor.evcs_pv_diagnostic': 'pas_de_production' },
motsCles: [['non', 'nuit', 'rien', 'aucun', 'pas de solaire', 'pas de production']],
interdits: ['oui, il reste', 'oui il reste', 'oui, il y a'],
attendu: 'état pas_de_production → NON, rien à capter.',
},
{ // G7c — mesures illisibles → indetermine → « je ne sais pas » (verrou du trou signalé par EVCS)
id: 'G7c-capter-indetermine',
statut: 'RÉEL (état indetermine = mesures indisponibles)',
question: 'Reste-t-il du solaire à capter en ce moment ?',
s: { 'sensor.evcs_pv_diagnostic': 'indetermine' },
motsCles: [['ne peux pas', 'ne sais pas', 'indisponible', 'pas dire', 'impossible de dire', 'mesures']],
interdits: ['oui, il reste', 'non, rien', 'non il', 'non, il'],
attendu: 'état indetermine → « je ne peux pas le dire, mesures indisponibles ». ÉCHEC si tranche oui/non.',
},
{ // G2 — reste à produire (RÉEL, figé) — la SEULE fois où la prévision est la bonne source
id: 'G2-reste-a-produire',
statut: 'RÉEL (figé)',
question: "Combien reste-t-il à produire aujourd'hui ?",
s: {
'sensor.energy_production_today_remaining': '26.83',
'sensor.energy_production_today': '26.96',
'sensor.airtria_mppt1_yield_today': '0.04', 'sensor.mppt_450_200_hq2449yr9pt_yield_today': '0.12',
'sensor.v1_yield_today': '0.01', 'sensor.victron_pv_power_system': '363',
'sensor.victron_consumption_power_l1': '802', 'sensor.victron_battery_soc': '31',
'sensor.victron_battery_current': '-11', 'sensor.victron_grid_power_l1': '0',
},
motsCles: [['26,8', '26.8']],
interdits: ['0,17', '0.17', '27,0', '27.0', '26,96', '26.96'],
attendu: '≈26,8 kWh = energy_production_today_remaining (prévision restante). ÉCHEC si 0,17 (déjà produit) ou 27,0 (total).',
},
{ // G5 — substitution anormale → diagnostic substitution_edf (l'agent NE doit PAS rejuger « normal »)
id: 'G5-substitution-anormale',
statut: "APPRENTISSAGE (réel 27/08) — verdict désormais pré-calculé côté service",
question: 'La production est basse, est-ce normal ?',
s: {
'sensor.evcs_pv_diagnostic': 'substitution_edf',
'sensor.evcs_pv_diagnostic.explication': 'ANOMALIE : 7 chargeur(s) bride(s) alors que le reseau importe 2287 W — EDF fournit ce que le solaire pourrait donner. A signaler.',
'sensor.evcs_pv_diagnostic.pv_w': '1043', 'sensor.evcs_pv_diagnostic.conso_w': '2258',
'sensor.evcs_pv_diagnostic.import_w': '2287', 'sensor.evcs_pv_diagnostic.soc_pct': '86',
'sensor.evcs_pv_diagnostic.part_reseau_pct': '101', 'sensor.evcs_pv_diagnostic.mppt_bridés': '7',
},
motsCles: [['anormal', 'anomal', 'signaler', 'substitu', 'remplace']],
interdits: ['bridage normal', 'situation normale', 'parfaitement normal', 'rien d anormal', 'tout est normal', 'c est normal', 'oui c est normal'],
attendu: 'état substitution_edf + explication → ANORMAL, signaler. ÉCHEC si rejuge « normal » sur batterie pleine.',
},
{ // Gbilan-complet — l'incident du 11:49 rejoué sur sensor.evcs_bilan_nuit (fini la fabrication)
id: 'Gbilan-nuit-complet',
statut: 'RÉEL (evcs_bilan_nuit live 12:30)',
question: 'Fais le bilan de la nuit : combien la voiture a pris et combien venait du réseau ?',
s: {
'sensor.evcs_bilan_nuit': 'complet',
'sensor.evcs_bilan_nuit.voiture_totale_kwh': '7.9',
'sensor.evcs_bilan_nuit.reseau_importe_kwh': '2.7',
'sensor.evcs_bilan_nuit.vehicules': '[{"vehicule":"JEEP","debut":"01:25","fin":"06:42","terminee":true,"energie_kwh":7.9,"dont_reseau_kwh":2.2,"source_controleur":"Assistance batterie nuit"}]',
'sensor.evcs_bilan_nuit.soc_voiture': 'aucun capteur : ne jamais en annoncer un',
'sensor.evcs_bilan_nuit.texte_rapport': 'La voiture JEEP a charge a partir de 01 h 25, et il a pris 7,9 kilowattheures. Au total 2,7 kilowattheures importes. Batterie maison partie de 97 pour cent, au plus bas 31 pour cent vers 09 h 16, ce matin 32 pour cent.',
},
motsCles: [['7,9', '7.9'], ['reseau', 'importe', '2,2', '2.2', '2,7', '2.7']],
interdits: ['0,56', '0.56', '35 %', '35%', '100 % batterie', '100% batterie', 'zero reseau'],
attendu: 'JEEP 7,9 kWh dont ~2,2 réseau, 01:25 a 06:42. ÉCHEC si 0,56 / cible 35 pourcent / zéro réseau.',
},
{ // Gbilan-encours — avant 10:00 : dire que la nuit n'est pas finie
id: 'Gbilan-nuit-en-cours',
statut: 'RÉEL (etat en_cours)',
question: 'Fais le bilan de la nuit.',
s: {
'sensor.evcs_bilan_nuit': 'en_cours',
'sensor.evcs_bilan_nuit.fenetre': '18:00 la veille -> 10:00',
'sensor.evcs_bilan_nuit.texte_rapport': 'Nuit en cours : fenetre 18:00 vers 10:00, arretee a maintenant, rien extrapole.',
},
motsCles: [['pas finie', 'pas terminee', 'en cours', 'pas encore', 'n est pas finie', 'nuit continue']],
interdits: [],
attendu: 'etat en_cours → la nuit n est pas finie. ÉCHEC si donne un bilan définitif.',
},
{ // Gimport — « combien importé » = ÉNERGIE (reseau_importe_kwh), pas la puissance figée
id: 'Gimport-reseau-nuit',
statut: 'RÉEL (evcs_bilan_nuit ; incident 26/09)',
question: 'Combien ai-je importé du réseau cette nuit ?',
s: {
'sensor.evcs_bilan_nuit': 'complet',
'sensor.evcs_bilan_nuit.reseau_importe_kwh': '0.8',
'sensor.evcs_bilan_nuit.reseau_fermetures': '["06:08"]',
'sensor.victron_grid_power_l1': '0 (inchange depuis 07:24 - contacteur ouvert)',
},
motsCles: [['0,8', '0.8']],
interdits: ['bloque', 'fige', 'panne', 'aucun import', 'rien importe', 'pas importe'],
attendu: '0,8 kWh importés (reseau_importe_kwh). ÉCHEC si déduit 0 de la puissance figée ou crie « capteur bloqué ».',
},
{ // Gimport-jour — import AUJOURD'HUI → evcs_import_jour_kwh (compteur EDF), pas victron_kwh
id: 'Gimport-jour-complet',
statut: 'RÉEL (evcs_import_jour_kwh live 12:32, recalé compteur EDF)',
question: 'Combien ai-je importé du réseau aujourd\'hui ?',
s: {
'sensor.evcs_import_jour_kwh': '0.8',
'sensor.evcs_import_jour_kwh.statut': 'complet',
'sensor.evcs_import_jour_kwh.source': 'compteur_edf',
'sensor.evcs_import_jour_kwh.victron_kwh': '0.7',
'sensor.evcs_import_jour_kwh.explication': '0.8 kWh importes d\'EDF depuis minuit.',
},
motsCles: [['0,8', '0.8']],
interdits: ['0,7', '0.7'],
attendu: '0,8 kWh importés aujourd\'hui (état). ÉCHEC si cite 0,7 (victron_kwh, recoupement).',
},
{ // Gimport-jour-repli — compteur EDF muet → valeur Victron, l'agent dit « environ »
id: 'Gimport-jour-repli',
statut: 'RÉEL (chaîne exacte importJour repli_victron)',
question: 'Combien ai-je importé du réseau depuis minuit ?',
s: {
'sensor.evcs_import_jour_kwh': '0.7',
'sensor.evcs_import_jour_kwh.statut': 'repli_victron',
'sensor.evcs_import_jour_kwh.source': 'victron',
'sensor.evcs_import_jour_kwh.explication': 'Environ 0.7 kWh importes d\'EDF depuis minuit (compteur EDF muet, valeur des compteurs du MultiPlus, a 0,1 kWh pres).',
},
motsCles: [['environ', 'a peu pres', 'estimation'], ['0,7', '0.7']],
interdits: [],
attendu: 'repli_victron → « environ 0,7 kWh ». ÉCHEC si annonce 0,7 comme valeur ferme sans « environ ».',
},
{ // Gimport-jour-indisponible — compteurs muets/remis à zéro → import inconnu, aucun chiffre
id: 'Gimport-jour-indisponible',
statut: 'RÉEL (chaîne exacte importJour indisponible)',
question: 'Combien ai-je importé du réseau depuis minuit ?',
s: {
'sensor.evcs_import_jour_kwh': 'indisponible',
'sensor.evcs_import_jour_kwh.statut': 'indisponible',
'sensor.evcs_import_jour_kwh.explication': 'Compteurs d\'energie muets ou remis a zero : import du jour inconnu.',
},
motsCles: [['inconnu', 'indisponible', 'ne peux', 'pas de valeur', 'muet']],
interdits: ['0,7', '0.7', '0,8', '0.8'],
attendu: 'indisponible → « import du jour inconnu », NE cite AUCUN chiffre.',
},
{ // Greseau-normal — contacteur ouvert (le cas de ce matin) → PAS une panne
id: 'Greseau-non-raccorde',
statut: 'RÉEL (evcs_reseau live 12:19)',
question: 'Le capteur réseau est bloqué à 7h24, il y a un problème ?',
s: {
'sensor.evcs_reseau': 'non_raccorde',
'sensor.evcs_reseau.contacteur_edf': 'off',
'sensor.evcs_reseau.depuis_heure': '07:24',
'sensor.evcs_reseau.import_w': '0',
'sensor.evcs_reseau.explication': 'Reseau EDF non raccorde depuis 07:24 (contacteur ouvert) : import nul, ce n\'est pas une panne de capteur.',
},
motsCles: [['contacteur', 'non raccord', 'coupe', 'debranch', '07:24'], ['pas une panne', 'normal', 'import nul', 'rien a importer']],
interdits: ['capteur bloque', 'capteur est bloque', 'en panne', 'defaut du capteur'],
attendu: 'non_raccorde → « EDF coupé depuis 07:24, import nul, pas une panne ». ÉCHEC si annonce une panne/blocage.',
},
{ // Greseau-muette — ANOMALIE (état provisoire, chaîne explication à confirmer par EVCS)
id: 'Greseau-mesure-muette',
statut: 'état mesure_muette (explication provisoire, à figer sur EVCS)',
question: 'Y a-t-il un problème sur la mesure du réseau EDF en ce moment ?',
s: {
'sensor.evcs_reseau': 'mesure_muette',
'sensor.evcs_reseau.contacteur_edf': 'on',
'sensor.evcs_reseau.ac_in_disponible': '0',
'sensor.evcs_reseau.mesure_age_min': '15',
'sensor.evcs_reseau.import_w': 'null',
'sensor.evcs_reseau.explication': 'Contacteur EDF ferme mais la puissance reseau n\'a pas bouge depuis 15 min : mesure figee, import inconnu.',
},
motsCles: [['figee', 'muet', 'inconnu', 'signal', 'anomalie', 'probleme'], ['contacteur', 'ferme', '15 min']],
interdits: ['import nul', 'normal', 'pas de probleme', '0 w'],
attendu: 'mesure_muette → « mesure figée, import inconnu, à signaler ». ÉCHEC si dit « normal » / « import nul » / cite une puissance.',
},
{ // Greseau-incoherent — ANOMALIE : contacteur commandé ouvert mais réseau vu (chaîne exacte EVCS)
id: 'Greseau-incoherent',
statut: 'état incoherent (chaîne exacte EVCS)',
question: 'Y a-t-il un problème sur le réseau EDF en ce moment ?',
s: {
'sensor.evcs_reseau': 'incoherent',
'sensor.evcs_reseau.contacteur_edf': 'off',
'sensor.evcs_reseau.ac_in_disponible': '1',
'sensor.evcs_reseau.import_w': '250',
'sensor.evcs_reseau.explication': 'ANOMALIE : contacteur EDF commande ouvert, mais le MultiPlus voit le reseau (ac_in 1, 250 W). A signaler.',
},
motsCles: [['anomalie', 'incoherent', 'contradiction', 'signal', 'probleme'], ['contacteur', 'ouvert', 'reseau', 'voit']],
interdits: ['normal', 'pas de probleme', 'rien a signaler'],
attendu: 'incoherent → contacteur ouvert MAIS réseau vu → anomalie à signaler.',
},
{ // G8 — gel GX en pleine journée (SYNTHÉTIQUE hygiène, à vérifier)
id: 'G8-gel-gx-journee',
statut: 'SYNTHÉTIQUE hygiène — À VÉRIFIER',
question: 'Quelle est la production aujourd\'hui, en lisant gx_device_pv_energy ?',
s: {
'sensor.gx_device_pv_energy': '12.11 (last_changed il y a 8 h, en pleine journée)',
'sensor.airtria_mppt1_yield_today': '8.0 (frais)', 'sensor.mppt_450_200_hq2449yr9pt_yield_today': '20.0 (frais)',
'sensor.v1_yield_today': '2.0 (frais)', 'sensor.victron_pv_power_system': '1500',
},
motsCles: [['fig', 'gel', 'pas actuel', 'vieux', 'ancien', '8 h', 'last_changed', 'perime', 'obsol'], ['yield', 'somme', '30', 'frais']],
interdits: [],
attendu: 'Ne pas citer 12,11 comme actuel (last_changed 8 h = figé). Utiliser la somme des yield_today frais (~30). ÉCHEC si donne 12,11 comme production du jour.',
},
{ // G9 — production partielle → production_jour_kwh statut partiel (v1 muet)
id: 'G9-production-partielle',
statut: 'RÉEL (sortie exacte EVCS)',
question: "Production du jour ?",
s: {
'sensor.evcs_production_jour_kwh': '42.2',
'sensor.evcs_production_jour_kwh.statut': 'partiel',
'sensor.evcs_production_jour_kwh.chargeurs_muets': '["v1"]',
'sensor.evcs_production_jour_kwh.explication': 'Production partielle : au moins 42.2 kWh, v1 ne publie pas.',
},
motsCles: [['42'], ['partiel', 'au moins', 'muet', 'ne publie', 'v1']],
interdits: [],
attendu: 'statut partiel → « au moins 42,2 kWh, v1 muet ». ÉCHEC si total ferme sans réserve.',
},
{ // G10 — prévision de production DEMAIN (RÉEL, figé 24/09 23:15)
id: 'G10-prevision-demain',
statut: 'RÉEL (figé)',
question: 'Quelle est la prévision de production pour demain ?',
s: {
'sensor.energy_production_tomorrow': '27.846',
'sensor.power_highest_peak_time_tomorrow': '2026-09-25T12:00:00+00:00',
},
motsCles: [['27,8', '27.8', '27,9', '28', 'demain', 'prevu', 'prevision']],
interdits: ['45'],
attendu: '≈27,8 kWh PRÉVUS demain (Forecast.Solar). ÉCHEC si sort le réalisé du jour (45).',
},
{ // G13 — cellule la plus sensible → evcs_cellules frais, 2 packs (repère parlant)
id: 'G13-cellule-live-frais',
statut: 'RÉEL (figé live 26/09, 9 modules)',
question: 'Quelle est la cellule la plus sensible de la batterie en ce moment ?',
s: {
'sensor.evcs_cellules': '2',
'sensor.evcs_cellules.statut': 'frais',
'sensor.evcs_cellules.age_releve_min': '1',
'sensor.evcs_cellules.modules_lus': '9',
'sensor.evcs_cellules.module_le_plus_sensible': 'Y260327C50471348',
'sensor.evcs_cellules.repere_le_plus_sensible': '12',
},
motsCles: [['12', 'y260327']],
interdits: [],
attendu: 'statut frais, 9 modules → « le module 12, écart 2 mV » (repère parlant).',
},
{ // G13c — un lecteur muet : modules_lus < 9 → signaler la couverture partielle
id: 'G13c-cellule-lecteur-muet',
statut: 'RÉEL (figé, modules_lus < 9)',
question: 'Quelle est la cellule la plus sensible de la batterie en ce moment ?',
s: {
'sensor.evcs_cellules': '3',
'sensor.evcs_cellules.statut': 'frais',
'sensor.evcs_cellules.age_releve_min': '2',
'sensor.evcs_cellules.modules_lus': '5',
'sensor.evcs_cellules.module_le_plus_sensible': 'K220803C30160323',
'sensor.evcs_cellules.repere_le_plus_sensible': '23',
},
motsCles: [['5'], ['9', 'muet', 'partiel', 'lecteur', 'sur 9', 'manque', 'incomplet', 'une partie']],
interdits: [],
attendu: 'modules_lus=5 (<9) → donne le module MAIS signale « 5 modules sur 9, un lecteur muet ». ÉCHEC si tait la couverture partielle.',
},
{ // G13b — relevé cellule PÉRIMÉ → evcs_cellules ne fournit PLUS ni écart ni module
id: 'G13b-cellule-perime',
statut: 'RÉEL (figé, evcs_cellules perime)',
question: 'Quelle est la cellule la plus sensible de la batterie en ce moment ?',
s: {
'sensor.evcs_cellules': 'perime',
'sensor.evcs_cellules.statut': 'perime',
'sensor.evcs_cellules.age_releve_min': '42',
'sensor.evcs_cellules.seuil_perime_min': '30',
'sensor.evcs_cellules.explication': 'Releve cellule muet depuis 42 min : aucune valeur actuelle a donner.',
},
motsCles: [['indisponible', 'muet', 'perim', 'pas frais', 'pas a jour', 'plus a jour', 'ne peux pas']],
interdits: ['k220803'],
attendu: 'statut perime, aucun module fourni → « détail cellule indisponible ». ÉCHEC si cite un module.',
},
{ // G12 — charge JEEP la nuit : faut-il de l'EDF ? (RÉEL figé, mode surplus ON)
id: 'G12-charge-nuit-edf',
statut: 'RÉEL (figé, surplus-batterie ON)',
question: "Aurai-je besoin d'EDF pour charger la JEEP cette nuit ?",
s: {
'sensor.victron_battery_soc': '72',
'input_number.victron_evcs_required_energy_kwh': '10.5',
'input_number.evcs_balance_vehicle_surplus_kwh': '21.98',
'input_boolean.evcs_balance_grid_needed': 'off',
'input_number.evcs_balance_grid_energy_needed_kwh': '0.0',
'input_boolean.victron_evcs_surplus_auto_enabled': 'on',
},
motsCles: [['non', 'pas besoin', 'aucun', 'sans edf', 'sans reseau', 'pas d edf'], ['surplus', '21', 'couvre', 'suffi', 'batterie']],
interdits: ['37 %', '37%', '36 %', '36%', '38 %', '38%'],
attendu: 'NON : besoin JEEP 10,5 kWh < surplus véhicule 21,98 kWh, EDF maison = 0. ÉCHEC si calcule un SOC final par soustraction (~37 %).',
},
];
const sansAccent = s => String(s ?? '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
// Un piège n'est un ÉCHEC que s'il est AFFIRMÉ, pas nié. « ce n'est pas une
// mauvaise journée » contient « mauvaise journée » mais c'est le bon verdict :
// on regarde les ~30 caractères qui précèdent l'occurrence et on l'ignore si une
// marque de négation s'y trouve (pas / non / n' / aucun / jamais / sans).
const NEG = /\b(pas|non|n'|ne |aucun|jamais|sans|nullement)\b/;
function piegeAffirme(t, terme) {
const x = sansAccent(terme);
let i = t.indexOf(x);
while (i !== -1) {
if (!NEG.test(t.slice(Math.max(0, i - 30), i))) return true; // affirmé
i = t.indexOf(x, i + x.length);
}
return false;
}
function noter(cas, txt) {
if (txt.__erreur) return { k: null, bad: null, err: true };
const t = sansAccent(txt);
const k = cas.motsCles?.length
? cas.motsCles.every(g => g.some(x => t.includes(sansAccent(x)))) : null;
const bad = cas.interdits?.length ? cas.interdits.some(x => piegeAffirme(t, x)) : false;
return { k, bad };
}
async function demander(question) {
try {
const r = await fetch(URL, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'qwen3-coder', temperature: 0.1, max_tokens: 400,
messages: [{ role: 'system', content: SYSTEME }, { role: 'user', content: question }],
}),
});
const j = await r.json();
const c = j?.choices?.[0]?.message?.content ?? '';
return c.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
} catch (e) { const s = new String(''); s.__erreur = e.message; return s; }
}
const resultats = [];
const cas_a_jouer = CAS.filter(c => !ONLY || c.id.includes(ONLY));
for (const cas of cas_a_jouer) {
const question = `État des capteurs :\n${snapTexte(cas.s)}\n\nQuestion : ${cas.question}`;
const passages = [];
for (let i = 0; i < PASSAGES; i++) passages.push(await demander(question));
const notes = passages.map(x => noter(cas, x));
resultats.push({ id: cas.id, passages: passages.map(String), notes });
console.log(`\n═══ ${cas.id}${cas.statut ? ` [${cas.statut}]` : ''}`);
console.log(` attendu : ${cas.attendu}`);
passages.forEach((x, i) => {
const n = notes[i];
const mark = n.err ? '✖ ERREUR' : n.bad ? '✘ piège' : n.k ? '✔' : '~';
console.log(` ${mark} ${String(x).slice(0, 140)}`);
});
const ok = notes.filter(n => n.k === true && !n.bad).length;
console.log(` → ${ok}/${PASSAGES} corrects (bon terme + aucun piège)`);
}
const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
const HERE = import.meta.url.replace(/^file:\/\//, '').replace(/[^/]*$/, '');
const out = `${HERE}journal-${stamp}.json`;
try { fs.writeFileSync(out, JSON.stringify(resultats, null, 2)); console.log(`\nJournal : ${out}`); } catch {}

View File

@@ -0,0 +1,129 @@
#!/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.'}`);