Plan du planificateur HC ; dialogue avec le conseiller dans HA ; arbitre encadre (question a choix fermes, liste blanche)

This commit is contained in:
Tallagrand Thierry
2026-10-08 07:26:14 +02:00
parent b25770736f
commit dea9c88530
16 changed files with 1114 additions and 3 deletions

View File

@@ -0,0 +1,27 @@
// =====================================================
// CONSEILLER — DIALOGUE : BUILD_HA_CALLS (08/10/2026)
// GARANTIE : n'ecrit QUE sensor.evcs_conseiller_reponse (via /api/states) et
// input_text.evcs_advisor_status. AUCUN switch, AUCUNE strategie, AUCUNE
// commande. Ne pas ajouter d'autres entites ici.
// =====================================================
const { status, reponse, avertissement, inventes, source, modelUsed, context } = $input.first().json;
const stamp = new Date().toLocaleString('fr-FR', { timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
return [{ json: { state_entity: 'sensor.evcs_conseiller_reponse', data: {
state: stamp,
attributes: {
friendly_name: 'Reponse du conseiller',
question: context.question,
question_at: context.questionAt,
statut: status,
reponse: reponse || (status === 'llm_offline' ? 'Le modele n a pas repondu (Spark et OpenRouter hors ligne). Reessaie en reecrivant la question.' : ''),
avertissement: avertissement || '',
chiffres_non_trouves: inventes || [],
modele: modelUsed,
source,
source_texte: source === 'openrouter'
? 'Repli OpenRouter (cloud) : le modele local de la Spark n a pas repondu, la question et les donnees ont ete envoyees a OpenRouter.'
: 'Modele local de la Spark : rien n est sorti de la maison.',
},
} } }];

View File

@@ -0,0 +1,72 @@
// =====================================================
// CONSEILLER — DIALOGUE DANS HOME ASSISTANT (08/10/2026, proprietaire)
// Le proprietaire ecrit une question dans input_text.evcs_conseiller_question ;
// ce workflow (toutes les minutes) la voit, interroge le modele avec l'etat du
// systeme, la revue du jour et ses pistes, et publie la reponse dans
// sensor.evcs_conseiller_reponse, affichee sur la carte du conseiller.
// Le conseiller EXPLIQUE, il ne commande rien (doctrine §79 : les chiffres
// viennent des mesures transmises, jamais de lui).
// =====================================================
const raw = $input.first().json;
const input = typeof raw.data === 'string' ? JSON.parse(raw.data) : (raw.data || raw);
const onOff = (v) => (v ?? 'off').toString().trim().toLowerCase() === 'on';
const txt = (v) => (v ?? '').toString().trim();
const question = txt(input.question);
const questionAt = Number(input.question_at) || 0;
const nouvelle = !!question && !['unknown', 'unavailable'].includes(question)
&& questionAt > (Number(input.derniere_question_at) || 0);
let pistes = input.revue_pistes;
if (typeof pistes === 'string') { try { pistes = JSON.parse(pistes); } catch { pistes = []; } }
if (!Array.isArray(pistes)) pistes = [];
const donnees = [
`Maintenant : ${txt(input.maintenant)} (heure de Paris).`,
`Batterie : SOC ${txt(input.soc_percent)} %, courant ${txt(input.battery_current_a)} A (positif = charge) ; courant de charge maximal fixe par l'ACM ${txt(input.acm_courant_max)} A.`,
`Solaire ${txt(input.pv_power_w)} W ; maison ${txt(input.house_ac_power_w)} W (borne comprise) ; reseau ${txt(input.grid_power_w)} W ; contacteur EDF ${txt(input.edf_contacteur)} ; session reseau d'heures creuses engagee : ${txt(input.session_hc)}.`,
`Aujourd'hui : production ${txt(input.production_jour_kwh)} kWh, consommation ${txt(input.consommation_jour_kwh)} kWh, achete a EDF ${txt(input.import_jour_kwh)} kWh.`,
`Borne : vehicule branche ${txt(input.vehicule_branche)} (selecteur ${txt(input.vehicule)}), puissance ${txt(input.borne_puissance_w)} W.`,
`Controleur de la borne : etat ${txt(input.controleur_etat)}, source ${txt(input.controleur_source)}, strategie ${txt(input.strategie)}, mode de nuit ${txt(input.mode_nuit)}. Raison : ${txt(input.controleur_raison)}`,
`Bilan energetique : SOC requis ${txt(input.bilan_requis)} %, cible du matin ${txt(input.cible_matin)} %. ${txt(input.bilan_raison)}`,
`Chauffe-eau : ${txt(input.chauffe_eau)}, decision ${txt(input.chauffe_eau_decision)}.`,
input.prevision_nuit ? `Prevision de la nuit : ${txt(input.prevision_nuit)}` : '',
`Revue de la journee (${txt(input.revue_jour_date)}) : ${txt(input.revue_jour_texte) || 'pas encore publiee'}`,
pistes.length ? `Pistes du conseiller pour cette revue : ${pistes.map((p) => `[${p.code}] ${p.piste}`).join(' ; ')}` : '',
].filter(Boolean).join('\n');
const systemPrompt = [
"Tu es le CONSEILLER du systeme energie d'une maison en Bretagne (16 kWc solaire,",
'batterie ~23 kWh utiles soit 0,30 kWh par point de SOC, Victron ESS SANS injection,',
'abonnement EDF 12 kVA, contacteur EDF ouvert la plupart du temps, heures creuses',
'01:24-07:24 et 12:24-14:24, borne pour trois vehicules : JEEP, VW, ZERO).',
'Le proprietaire te pose une question. Reponds en francais, clairement, en 1 500',
'caracteres au plus.',
'',
'VOCABULAIRE (ne te trompe pas) :',
'- contacteur EDF « on » = EDF FERME = la maison est raccordee au reseau ; « off » = EDF',
' ouvert = la maison vit sur batterie et solaire. Le proprietaire dit « EDF ferme » pour on.',
'- ACM = « Auto Charge Manager », le workflow qui fixe le courant de charge maximal de la',
' batterie ; le controleur = celui de la borne ; le bilan = le calcul du SOC requis.',
'- mode de nuit sans_edf : la voiture ne fait jamais fermer EDF a elle seule ; complete :',
' la voiture charge sur EDF en heures creuses.',
'',
'Reponds d abord directement a la question, puis explique.',
'',
'REGLES ABSOLUES :',
'- Tu n utilises QUE les donnees ci-dessous. N invente aucun chiffre : si une valeur',
' manque, dis « je n ai pas cette donnee ».',
'- Tu ne commandes rien et tu ne pretends pas avoir agi. Si une action semble utile,',
' dis laquelle et laisse le proprietaire decider.',
'- Distingue ce qui est MESURE (les donnees) de ce que tu SUPPOSES (dis « je suppose »).',
].join('\n');
return [{ json: {
enabled: onOff(input.advisor_enabled) && nouvelle,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: `DONNEES MESUREES :\n${donnees}\n\nQUESTION : ${question}` },
],
context: { question, questionAt, donnees },
} }];

View File

@@ -0,0 +1,36 @@
{{ {
"advisor_enabled": states("input_boolean.evcs_advisor_enabled"),
"question": states("input_text.evcs_conseiller_question"),
"question_at": as_timestamp(states.input_text.evcs_conseiller_question.last_changed) | int(0) if states.input_text.evcs_conseiller_question is not none else 0,
"derniere_question_at": state_attr("sensor.evcs_conseiller_reponse", "question_at") | int(0),
"maintenant": now().strftime("%d/%m %H:%M"),
"soc_percent": states("sensor.victron_battery_soc"),
"battery_current_a": states("sensor.victron_battery_current"),
"pv_power_w": states("sensor.victron_pv_power_system"),
"house_ac_power_w": states("sensor.victron_consumption_power_l1"),
"grid_power_w": states("sensor.victron_grid_power_l1"),
"edf_contacteur": states("switch.edf_general_ok_switch"),
"session_hc": states("input_boolean.victron_evcs_night_grid_session_committed"),
"production_jour_kwh": states("sensor.evcs_production_jour_kwh"),
"import_jour_kwh": states("sensor.evcs_import_jour_kwh"),
"consommation_jour_kwh": states("sensor.evcs_consommation_jour_kwh"),
"vehicule_branche": states("binary_sensor.victron_evcs_vehicle_connected"),
"vehicule": states("input_select.victron_evcs_vehicle"),
"borne_puissance_w": states("sensor.victron_evcs_ac_power"),
"controleur_etat": states("input_text.victron_evcs_surplus_state"),
"controleur_source": states("input_text.victron_evcs_source_mode"),
"controleur_raison": states("input_text.victron_evcs_surplus_reason")[:250],
"strategie": states("input_select.victron_evcs_strategy"),
"mode_nuit": states("input_select.victron_evcs_night_charge_mode"),
"cible_matin": states("input_number.victron_evcs_night_target_soc"),
"acm_courant_max": states("sensor.victron_max_charge_current_setting"),
"bilan_raison": states("input_text.evcs_balance_reason"),
"bilan_requis": states("input_number.evcs_balance_required_soc"),
"chauffe_eau": states("switch.tt_chauffe_eau_pleumeur_switch"),
"chauffe_eau_decision": states("input_text.victron_water_heater_advice_state"),
"prevision_nuit": state_attr("sensor.evcs_prevision_nuit", "texte_rapport") or "",
"revue_jour_date": states("sensor.evcs_revue_jour"),
"revue_jour_texte": state_attr("sensor.evcs_revue_jour", "texte") or "",
"revue_pistes": state_attr("sensor.evcs_advisor_revue", "pistes") or []
} | tojson }}

View File

@@ -0,0 +1,31 @@
// =====================================================
// CONSEILLER — DIALOGUE : VALIDATE_PARSE (08/10/2026)
// Reponse en texte libre. ANTI-INVENTION : les nombres de la reponse absents des
// donnees transmises (et de la question) sont SIGNALES en tete de reponse, pas
// caches : le proprietaire juge.
// =====================================================
const resp = $input.first().json;
const context = $('BUILD_PROMPT').first().json.context;
const viaCloud = (() => { try { return $('LLM_CLOUD').isExecuted === true; } catch { return false; } })()
|| typeof resp?.provider === 'string' || /^gen-/.test(String(resp?.id || ''));
const source = viaCloud ? 'openrouter' : 'local';
const modelUsed = (resp?.model || 'aucun').toString().slice(0, 60);
const content = resp?.choices?.[0]?.message?.content;
if (typeof content !== 'string' || !content.trim()) {
return [{ json: { status: 'llm_offline', reponse: null, avertissement: '', source, modelUsed, context } }];
}
const reponse = content.replace(/<think>[\s\S]*?<\/think>/gi, '').replace(/```[a-z]*/gi, '').trim().slice(0, 3000);
const nombres = (s) => (String(s).match(/\d+(?:[.,]\d+)?/g) || []).map((x) => String(Number(x.replace(',', '.'))));
// Une mesure publiee 1.2000000298 est citee « 1,2 » : on admet ses arrondis.
const permis = new Set(nombres(`${context.donnees} ${context.question}`)
.flatMap((n) => [n, String(Math.round(Number(n))), String(Math.round(Number(n) * 10) / 10), String(Math.round(Number(n) * 100) / 100)]));
// Les petits entiers (1 a 3) servent aux enumerations ; ils ne sont pas des mesures.
const inventes = [...new Set(nombres(reponse).filter((n) => !permis.has(n) && !['0', '1', '2', '3'].includes(n)))];
const avertissement = inventes.length
? `Attention : chiffre(s) absent(s) des donnees transmises : ${inventes.join(', ')}. A verifier.`
: '';
return [{ json: { status: 'ok', reponse, avertissement, inventes, source, modelUsed, context } }];