#!/usr/bin/env node // Campagne de rejeu du conseiller IA sur des instants REELS. // Chaque cas rejoue un moment mesure dont on connait la suite. Cinq passages // par cas : un seul ne mesure rien, le modele n'est pas deterministe, et // l'ECART entre passages est souvent plus parlant que la justesse. // node tools/campagne-conseiller.mjs [passages] import fs from 'fs'; import vm from 'vm'; const PASSAGES = Number(process.argv[2] || 5); const URL = 'http://10.10.10.8:8000/v1/chat/completions'; const code = fs.readFileSync('src/advisor/BUILD_PROMPT.js', 'utf8'); function buildPrompt(snapshot, nowIso) { const ctx = vm.createContext({ $input: { first: () => ({ json: { data: JSON.stringify(snapshot) } }) }, Intl, Date: class extends Date { constructor(...a) { super(...(a.length ? a : [nowIso])); } }, Math, Number, String, JSON, console, }); return vm.runInContext(`(function(){ ${code} })()`, ctx)[0].json; } const BASE = { advisor_enabled: 'on', advisor_agree_count: '0', advisor_diverge_count: '0', advisor_prev_strategy: 'auto_solar_plus', soc_percent: '50', battery_current_a: '0', pv_power_w: '0', pv_power_10m_avg_w: '0', house_ac_power_w: '700', grid_state: 'off', acin1_available: '0', evcs_vehicle_connected: 'off', evcs_charging: 'off', evcs_current_a: '0', evcs_surplus_state: 'vehicle_not_connected', evcs_source_mode: 'Bloqué', user_strategy: 'auto_solar_plus', allow_grid_hc_night: 'on', allow_grid_hc_midday: 'off', night_target_soc_actual: '35', current_max_auto_a: '22', plafond_edf_on_a: '20', departure_time: '', departure_priority_enabled: 'off', required_energy_kwh: '0', today_remaining_kwh: '0', tomorrow_kwh: '30', balance_status: 'ok', balance_last_run: 'recent', balance_required_soc: '40', balance_required_soc_prudent: '46', balance_energy_to_cover_kwh: '2.0', balance_crossing_hour: '09 h', balance_grid_needed: 'off', balance_grid_energy_needed_kwh: '0', balance_pv_censored: 'off', balance_censor_reason: '', balance_storage_shortfall_kwh: '6.8', balance_next_deficit_kwh: '2.0', balance_next_deficit_window: '(cette nuit)', balance_storm_level: 'Vert', balance_storm_reserve_soc: '38', modules_en_service: '4', bms_max_discharge_current_a: '100', bms_max_charge_current_a: '100', grid_setpoint_w: '700', mppt_operation_modes: 'off,off,off,off,off,off', vehicle_selected: 'VW', evenements_recents: '', }; const LIM = Array(6).fill('voltage_current_limited').join(','); const MAX = Array(6).fill('mppt_active').join(','); export const CAS = [ // ─────────── NUIT ET RESEAU ─────────── { id:'1-nuit-bloquee-sans-vehicule', t:'2026-09-12T04:00:00+02:00', certain:true, titre:"12/09 04:00 — SOC 20, HC ouvertes, aucun vehicule, bilan 59,1 %", attendu:"SIGNALER : reseau engage mais la batterie reste plate a 20 % alors que le bilan reclame 3,4 kWh", graviteAttendue:["moyenne","haute"], motsCles:[["recharg","remonte","ne monte","stagne","plate","0 a","0a","immobile"]], s:{ soc_percent:'20', battery_current_a:'0', house_ac_power_w:'612', grid_state:'on', acin1_available:'1', night_target_soc_actual:'59.1', tomorrow_kwh:'75.2', balance_required_soc:'59.1', balance_required_soc_prudent:'66.2', balance_energy_to_cover_kwh:'3.13', balance_grid_needed:'on', balance_grid_energy_needed_kwh:'3.4', balance_storage_shortfall_kwh:'6.77' } }, { id:'2-nuit-relachee-trop-tot', t:'2026-09-03T03:00:00+02:00', certain:true, titre:"03/09 03:00 — SOC 45, bilan 50,4 % (releche a tort : batterie a 20 % a 06:30)", attendu:"SIGNALER : SOC sous le requis alors que la session est marquee terminee", graviteAttendue:["faible","moyenne","haute"], motsCles:[["sous","en dessous","inferieur","manque","deficit","insuffisant"],["terminee","done","fini","relach","session"]], s:{ soc_percent:'45', battery_current_a:'2', house_ac_power_w:'700', grid_state:'on', acin1_available:'1', evcs_vehicle_connected:'on', evcs_surplus_state:'night_session_done_idle', evcs_source_mode:'Réseau HC autorisé', night_target_soc_actual:'50.4', tomorrow_kwh:'33.9', balance_required_soc:'50.4', balance_required_soc_prudent:'57', balance_energy_to_cover_kwh:'4.3', balance_grid_needed:'on', balance_grid_energy_needed_kwh:'1.1' } }, { id:'3-nuit-couverte-ne-rien-faire', t:'2026-09-12T04:00:00+02:00', certain:true, titre:"CONTREFACTUEL — meme nuit mais SOC 70, au-dessus des 59,1 % requis", attendu:"NE RIEN SIGNALER : au-dessus de la ligne, reseau ouvert, tout est coherent", graviteAttendue:["aucune"], s:{ soc_percent:'70', battery_current_a:'-12', house_ac_power_w:'612', grid_state:'off', acin1_available:'0', night_target_soc_actual:'59.1', tomorrow_kwh:'75.2', balance_required_soc:'59.1', balance_required_soc_prudent:'66.2', balance_energy_to_cover_kwh:'3.13', balance_grid_needed:'off' } }, { id:'4-hiver-nuit-longue', t:'2026-12-15T23:30:00+01:00', certain:false, titre:"HIVER — 15/12 23:30, prevision 6 kWh demain, croisement tardif a 11 h", attendu:"OUVERT : voit-il l ecart entre la cible 85 et les 88 % requis, et le deficit de stockage ?", graviteAttendue:null, s:{ soc_percent:'55', battery_current_a:'-18', house_ac_power_w:'1400', grid_state:'off', acin1_available:'0', night_target_soc_actual:'85', tomorrow_kwh:'6.2', balance_required_soc:'88', balance_required_soc_prudent:'97', balance_energy_to_cover_kwh:'9.4', balance_grid_needed:'on', balance_grid_energy_needed_kwh:'5.1', balance_crossing_hour:'11 h', balance_storage_shortfall_kwh:'8.2' } }, // ─────────── SOLAIRE ET VOITURE ─────────── { id:'5-soir-deficit-ne-pas-demarrer', t:'2026-09-03T19:44:00+02:00', certain:true, titre:"03/09 19:44 — PV 758 W mais maison 1202 W : excedent NEGATIF (-444 W)", attendu:"CALME (le correctif du 03/09 tient) : la charge ne demarre pas, excedent negatif", graviteAttendue:["aucune","faible"], s:{ soc_percent:'94', battery_current_a:'-12.2', pv_power_w:'758', pv_power_10m_avg_w:'900', house_ac_power_w:'1202', evcs_vehicle_connected:'on', vehicle_selected:'VW', evcs_source_mode:'Potentiel solaire', evcs_surplus_state:'idle_wait_conditions', mppt_operation_modes:MAX, balance_required_soc:'99.7', balance_required_soc_prudent:'100', balance_energy_to_cover_kwh:'11.66', balance_grid_needed:'on', tomorrow_kwh:'33.9' } }, { id:'6-censure-demarrer', t:'2026-09-03T18:50:00+02:00', certain:true, titre:"03/09 18:50 — PV 769 W, maison 755 W, chargeurs BRIDES : ressource refusee", attendu:"SIGNALER : ressource refusee et vehicule branche a l arret, energie perdue", graviteAttendue:["faible","moyenne"], s:{ soc_percent:'94', battery_current_a:'0', pv_power_w:'769', pv_power_10m_avg_w:'800', house_ac_power_w:'755', evcs_vehicle_connected:'on', vehicle_selected:'VW', evcs_source_mode:'Potentiel solaire', evcs_surplus_state:'idle_wait_conditions', mppt_operation_modes:LIM, balance_pv_censored:'on', balance_censor_reason:'batterie_pleine_sans_puits', balance_required_soc:'99.7', balance_required_soc_prudent:'100', balance_energy_to_cover_kwh:'11.66', balance_grid_needed:'on', balance_grid_energy_needed_kwh:'4.9', tomorrow_kwh:'33.9' } }, { id:'7-boost-evince-le-solaire', t:'2026-09-11T17:02:00+02:00', certain:true, titre:"11/09 17:02 — BOOST 28 A, consigne 8260 W, PV effondre a 121 W", attendu:"SIGNALER : la consigne reseau evince le solaire — l ancrage du prompt dit moyenne", graviteAttendue:["moyenne","haute"], s:{ soc_percent:'96', battery_current_a:'0', pv_power_w:'121', pv_power_10m_avg_w:'150', house_ac_power_w:'7695', evcs_vehicle_connected:'on', evcs_charging:'on', evcs_current_a:'28', vehicle_selected:'JEEP', user_strategy:'force_grid_now', grid_state:'on', acin1_available:'1', grid_setpoint_w:'8260', evcs_source_mode:'Réseau forcé', evcs_surplus_state:'charging_hold_grid', mppt_operation_modes:LIM, plafond_edf_on_a:'28', balance_pv_censored:'on', balance_censor_reason:'production_bridee', balance_required_soc:'62', balance_required_soc_prudent:'70', balance_energy_to_cover_kwh:'5.2', balance_grid_needed:'on', balance_grid_energy_needed_kwh:'1.4', balance_crossing_hour:'09 h', today_remaining_kwh:'8.4', tomorrow_kwh:'75.2' } }, { id:'8-voiture-pleine-ne-pas-reessayer', t:'2026-09-07T14:28:00+02:00', certain:true, titre:"07/09 14:28 — borne annonce charge terminee, vehicule branche immobile", attendu:"CALME : l etat collant fait son travail, aucune relance", graviteAttendue:["aucune","faible"], s:{ soc_percent:'97', battery_current_a:'1', pv_power_w:'2114', pv_power_10m_avg_w:'2000', house_ac_power_w:'1965', evcs_vehicle_connected:'on', evcs_current_a:'0', vehicle_selected:'JEEP', evcs_surplus_state:'day_vehicle_full_idle', evcs_source_mode:'Bloqué', mppt_operation_modes:LIM, balance_pv_censored:'on', balance_censor_reason:'batterie_pleine_sans_puits', balance_required_soc:'58', balance_required_soc_prudent:'66', balance_energy_to_cover_kwh:'4.6', balance_grid_needed:'off', balance_crossing_hour:'09 h', today_remaining_kwh:'12', tomorrow_kwh:'30' } }, // ─────────── PROTECTION DU MATERIEL ─────────── { id:'9-pointe-cuisson-proteger-le-parc', t:'2026-09-05T20:40:00+02:00', certain:true, titre:"05/09 20:40 — maison 4944 W, batterie a -97,4 A pour une limite de 100 A", attendu:"SIGNALER GRAVE : le parc est a 97,4 A pour une limite de 100 A, reseau ouvert", graviteAttendue:["haute"], motsCles:[["limite","plafond","maximum","97","capacite","au bord","saturation"]], s:{ soc_percent:'79', battery_current_a:'-97.4', pv_power_w:'320', pv_power_10m_avg_w:'500', house_ac_power_w:'4944', grid_state:'off', acin1_available:'0', balance_required_soc:'62', balance_required_soc_prudent:'70', balance_energy_to_cover_kwh:'5.1', balance_grid_needed:'on', tomorrow_kwh:'32' } }, { id:'10-nuage-sur-charge-28a', t:'2026-09-11T18:00:00+02:00', certain:true, titre:"11/09 18:00 — voiture a 28 A, consigne descendue a 1281 W, PV 6058 W", attendu:"SIGNALER : consigne a 1281 W sous une voiture a 28 A, un nuage tombe sur la batterie", graviteAttendue:["faible","moyenne","haute"], motsCles:[["nuage","marge","brutal","chute","passage","si le solaire"]], s:{ soc_percent:'96', battery_current_a:'-0.7', pv_power_w:'6058', pv_power_10m_avg_w:'5500', house_ac_power_w:'6845', evcs_vehicle_connected:'on', evcs_charging:'on', evcs_current_a:'28', vehicle_selected:'JEEP', user_strategy:'force_grid_now', grid_state:'on', acin1_available:'1', grid_setpoint_w:'1281', evcs_source_mode:'Réseau forcé', evcs_surplus_state:'charging_hold_grid', mppt_operation_modes:MAX, plafond_edf_on_a:'28', balance_required_soc:'62', balance_required_soc_prudent:'70', balance_energy_to_cover_kwh:'5.2', balance_grid_needed:'on', balance_grid_energy_needed_kwh:'1.4', balance_crossing_hour:'09 h', today_remaining_kwh:'4', tomorrow_kwh:'75.2' } }, // ─────────── CAS OUVERTS ─────────── { id:'11-batterie-pleine-midi', t:'2026-09-11T12:00:00+02:00', certain:false, titre:"11/09 12:00 — SOC 98, production refusee, AUCUN vehicule branche", attendu:"OUVERT : production refusee sans preneur, aucun vehicule. Voit-il l energie perdue ?", graviteAttendue:null, s:{ soc_percent:'98', battery_current_a:'2.2', pv_power_w:'810', pv_power_10m_avg_w:'900', house_ac_power_w:'702', mppt_operation_modes:LIM, balance_pv_censored:'on', balance_censor_reason:'batterie_pleine_sans_puits', balance_required_soc:'55', balance_required_soc_prudent:'63', balance_energy_to_cover_kwh:'4.1', balance_grid_needed:'off', balance_crossing_hour:'09 h', balance_next_deficit_kwh:'4.1', balance_next_deficit_window:'(ce soir 20 h -> 09 h)', today_remaining_kwh:'45', tomorrow_kwh:'75.2' } }, { id:'12-orage-annonce', t:'2026-09-12T15:30:00+02:00', certain:false, titre:"Vigilance ORANGE annoncee, SOC 72, coupures frequentes sur ce site", attendu:"OUVERT : vigilance orange, reserve 88 % contre SOC 72. Quel arbitrage ?", graviteAttendue:null, s:{ soc_percent:'72', battery_current_a:'35', pv_power_w:'4200', pv_power_10m_avg_w:'4000', house_ac_power_w:'900', mppt_operation_modes:MAX, balance_storm_level:'Orange', balance_storm_reserve_soc:'88', balance_required_soc:'55', balance_required_soc_prudent:'62', balance_energy_to_cover_kwh:'3.2', balance_grid_needed:'off', balance_crossing_hour:'09 h', today_remaining_kwh:'18', tomorrow_kwh:'40' } }, { id:'13-voiture-empruntee-echeance', t:'2026-09-11T17:00:00+02:00', certain:false, titre:"11/09 17:00 — voiture EMPRUNTEE a rendre a 19 h, 28 A possible, soleil present", attendu:"OUVERT : echeance 19 h contre solaire. Voit-il l arbitrage humain ?", graviteAttendue:null, s:{ soc_percent:'96', battery_current_a:'-10', pv_power_w:'4255', pv_power_10m_avg_w:'4000', house_ac_power_w:'4888', evcs_vehicle_connected:'on', evcs_charging:'on', evcs_current_a:'20', vehicle_selected:'JEEP', user_strategy:'force_charge_now', grid_state:'on', acin1_available:'1', grid_setpoint_w:'700', evcs_source_mode:'Solaire', evcs_surplus_state:'charging_hold_manual_local', mppt_operation_modes:MAX, plafond_edf_on_a:'28', required_energy_kwh:'12', departure_time:'19:00', departure_priority_enabled:'on', balance_required_soc:'62', balance_required_soc_prudent:'70', balance_energy_to_cover_kwh:'5.2', balance_grid_needed:'on', balance_grid_energy_needed_kwh:'1.4', balance_crossing_hour:'09 h', today_remaining_kwh:'6', tomorrow_kwh:'75.2' } }, { id:'4b-hiver-parc-12', t:'2026-12-15T23:30:00+01:00', certain:false, titre:"HIVER avec le parc de cet hiver — 12 equivalents (4 US2000C + 4 US5000)", attendu:"OUVERT : le parc triple change-t-il sa lecture de la nuit d hiver ?", graviteAttendue:null, s:{ soc_percent:'55', battery_current_a:'-18', house_ac_power_w:'1400', grid_state:'off', acin1_available:'0', night_target_soc_actual:'85', tomorrow_kwh:'6.2', balance_required_soc:'52', balance_required_soc_prudent:'59', balance_energy_to_cover_kwh:'9.4', balance_grid_needed:'on', balance_grid_energy_needed_kwh:'0', balance_crossing_hour:'11 h', balance_storage_shortfall_kwh:'0', modules_en_service:'12', bms_max_discharge_current_a:'300', bms_max_charge_current_a:'300' } }, { id:'4c-hiver-parc-16', t:'2026-12-15T23:30:00+01:00', certain:false, titre:"HIVER avec 16 equivalents (vieux modules repares)", attendu:"OUVERT : et avec 16 equivalents ?", graviteAttendue:null, s:{ soc_percent:'55', battery_current_a:'-18', house_ac_power_w:'1400', grid_state:'off', acin1_available:'0', night_target_soc_actual:'85', tomorrow_kwh:'6.2', balance_required_soc:'44', balance_required_soc_prudent:'50', balance_energy_to_cover_kwh:'9.4', balance_grid_needed:'off', balance_grid_energy_needed_kwh:'0', balance_crossing_hour:'11 h', balance_storage_shortfall_kwh:'0', modules_en_service:'16', bms_max_discharge_current_a:'400', bms_max_charge_current_a:'400' } }, { id:'15-oscillation-contacteur', t:'2026-09-12T06:36:00+02:00', certain:true, titre:"INCIDENT 12/09 06:25-06:35 — le contacteur bascule six fois en dix minutes", attendu:"SIGNALER GRAVE : six bascules en dix minutes, nommer le conflit entre ecrivains", graviteAttendue:["haute"], motsCles:[["oscill","bascul","va-et-vient","aller-retour","repet","six fois","6 fois","cycl"],["conflit","deux","ecriv","contradict","se battent","concurren","automatisation","hysteresis"]], s:{ soc_percent:'35', battery_current_a:'20', pv_power_w:'0', house_ac_power_w:'950', grid_state:'on', acin1_available:'1', night_target_soc_actual:'42.8', balance_required_soc:'42.8', balance_required_soc_prudent:'49', balance_energy_to_cover_kwh:'1.56', balance_grid_needed:'on', balance_grid_energy_needed_kwh:'1.1', balance_crossing_hour:'09 h', grid_setpoint_w:'5759', tomorrow_kwh:'75.2', evenements_recents:'06:35:06 Contacteur EDF : off -> on|06:34:02 Contacteur EDF : on -> off|06:30:49 Contacteur EDF : off -> on|06:29:44 Contacteur EDF : on -> off|06:26:31 Contacteur EDF : off -> on|06:25:27 Contacteur EDF : on -> off' } }, { id:'16-systeme-calme', t:'2026-09-12T07:00:00+02:00', certain:true, titre:"CONTREFACTUEL — meme nuit, contacteur stable depuis 90 minutes", attendu:"NE RIEN SIGNALER : meme nuit, contacteur stable depuis 90 minutes", graviteAttendue:["aucune"], s:{ soc_percent:'42', battery_current_a:'20', pv_power_w:'0', house_ac_power_w:'950', grid_state:'on', acin1_available:'1', night_target_soc_actual:'42.8', balance_required_soc:'42.8', balance_required_soc_prudent:'49', balance_energy_to_cover_kwh:'0.9', balance_grid_needed:'on', balance_crossing_hour:'09 h', grid_setpoint_w:'5759', tomorrow_kwh:'75.2', evenements_recents:'05:02:51 Contacteur EDF : off -> on|05:02:51 Reprise reseau nuit : off -> on' } }, { id:'14-bilan-indisponible', t:'2026-09-12T04:00:00+02:00', certain:true, titre:"ROBUSTESSE — meme nuit que le cas 1 mais bilan DEGRADE", attendu:"SIGNALER en le disant : le bilan est degrade, donc prudence assumee", graviteAttendue:["faible","moyenne","haute"], motsCles:[["degrad","indisponible","sans bilan","pas de bilan","inconnu","unknown"]], s:{ soc_percent:'20', battery_current_a:'0', house_ac_power_w:'612', grid_state:'on', acin1_available:'1', night_target_soc_actual:'35', tomorrow_kwh:'75.2', balance_status:'degrade', balance_required_soc:'unknown', balance_energy_to_cover_kwh:'unknown' } }, ]; // ── Notation ──────────────────────────────────────────────────────────────── // Un observateur ne se juge pas a une valeur qu'il propose mais a sa capacite // a SEPARER un systeme qui derape d'un systeme qui va bien. D'ou deux mesures // seulement : la gravite tombe-t-elle dans la fourchette attendue, et a-t-il // NOMME la chose (mots-cles choisis hors du vocabulaire du prompt). // Les cas ouverts ne sont pas notes : ils se lisent. const sansAccent = s => String(s ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase(); function noter(cas, x) { if (x.erreur) return { g: false, k: false }; const g = cas.graviteAttendue ? cas.graviteAttendue.includes(String(x.gravite)) : null; const texte = sansAccent(`${x.constat} ${x.ce_qui_cloche} ${x.piste}`); const k = cas.motsCles?.length ? cas.motsCles.every(groupe => groupe.some(t => texte.includes(sansAccent(t)))) : null; return { g, k }; } async function demander(messages) { const r = await fetch(URL, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ model:'qwen3-coder', temperature:0.1, max_tokens:600, messages }) }); const j = await r.json(); const c = j?.choices?.[0]?.message?.content ?? ''; const t = c.replace(/[\s\S]*?<\/think>/gi,'').replace(/```(?:json)?/gi,'').trim(); const a = t.indexOf('{'); if (a === -1) return { erreur: t.slice(0,120) }; let d=0,b=-1; for (let i=a;i noter(cas, x)); const gravites = passages.map(x => x.erreur ? '✖' : String(x.gravite)); const distinctes = [...new Set(gravites)]; resultats.push({ ...cas, passages, notes }); console.log(`\n═══ ${cas.id}${cas.graviteAttendue ? '' : ' [OUVERT]'}`); console.log(` ${cas.titre}`); console.log(` attendu : ${cas.attendu}`); for (let i = 0; i < passages.length; i++) { const x = passages[i], n = notes[i]; if (x.erreur) { console.log(` ✖ ${x.erreur}`); continue; } const marque = n.g === null ? ' ' : n.g ? '✔' : '✘'; const mot = n.k === null ? '' : n.k ? ' [nomme]' : ' [ne nomme pas]'; console.log(` ${marque} [${String(x.gravite).padEnd(7)}] ${String(x.constat).slice(0,92)}`); if (x.ce_qui_cloche) console.log(` ↳ ${String(x.ce_qui_cloche).slice(0,120)}`); if (x.piste) console.log(` → ${String(x.piste).slice(0,120)}${mot}`); else if (mot) console.log(` ${mot.trim()}`); } const justes = notes.filter(n => n.g === true).length; const nommes = notes.filter(n => n.k === true).length; console.log(` gravites : ${gravites.join(' ')} ${distinctes.length === 1 ? 'STABLE' : `INSTABLE (${distinctes.length} valeurs)`}`); if (cas.graviteAttendue) console.log(` gravite dans la fourchette ${cas.graviteAttendue.join('|')} : ${justes}/${PASSAGES}`); if (cas.motsCles?.length) console.log(` a nomme la chose : ${nommes}/${PASSAGES}`); } // ── Verdict : la separation incident / systeme calme ───────────────────────── const notes = c => c.notes.filter(n => n.g === true).length; const incidents = resultats.filter(c => c.graviteAttendue && !c.graviteAttendue.includes('aucune')); const calmes = resultats.filter(c => c.graviteAttendue && c.graviteAttendue.includes('aucune')); const ouverts = resultats.filter(c => !c.graviteAttendue); const somme = l => l.reduce((s, c) => s + notes(c), 0); console.log('\n────────────────────────────────────────────────────────────'); console.log(`INCIDENTS : ${somme(incidents)}/${incidents.length * PASSAGES} gravites justes (${incidents.length} cas)`); for (const c of incidents) { const nommes = c.motsCles?.length ? ` nomme ${c.notes.filter(n => n.k === true).length}/${PASSAGES}` : ''; console.log(` ${String(notes(c)).padStart(2)}/${PASSAGES} ${c.id}${nommes}`); } console.log(`SYSTEME CALME : ${somme(calmes)}/${calmes.length * PASSAGES} (silence attendu) (${calmes.length} cas)`); for (const c of calmes) console.log(` ${String(notes(c)).padStart(2)}/${PASSAGES} ${c.id}`); console.log(`CAS OUVERTS : ${ouverts.length}, non notes, a lire`); const fichier = 'docs/mesures/campagne-conseiller-' + new Date().toISOString().slice(0,10) + '.json'; fs.writeFileSync(fichier, JSON.stringify(resultats, null, 2)); console.log(`\nJournal complet : ${fichier}`);