#!/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=] '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(/[\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 {}