422 lines
20 KiB
JSON
422 lines
20 KiB
JSON
{
|
|
"name": "EVCS Conseiller Dialogue V1",
|
|
"nodes": [
|
|
{
|
|
"name": "Schedule Trigger",
|
|
"type": "n8n-nodes-base.scheduleTrigger",
|
|
"typeVersion": 1.2,
|
|
"position": [
|
|
0,
|
|
0
|
|
],
|
|
"parameters": {
|
|
"rule": {
|
|
"interval": [
|
|
{
|
|
"field": "minutes",
|
|
"minutesInterval": 1
|
|
}
|
|
]
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"name": "HA Snapshot Dialogue",
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.2,
|
|
"position": [
|
|
200,
|
|
0
|
|
],
|
|
"parameters": {
|
|
"method": "POST",
|
|
"url": "http://10.10.10.17:8123/api/template",
|
|
"authentication": "genericCredentialType",
|
|
"genericAuthType": "httpBearerAuth",
|
|
"sendHeaders": true,
|
|
"headerParameters": {
|
|
"parameters": [
|
|
{
|
|
"name": "Content-Type",
|
|
"value": "application/json"
|
|
},
|
|
{
|
|
"name": "Accept",
|
|
"value": "text/plain"
|
|
}
|
|
]
|
|
},
|
|
"sendBody": true,
|
|
"bodyParameters": {
|
|
"parameters": [
|
|
{
|
|
"name": "template",
|
|
"value": "{{ {\n \"advisor_enabled\": states(\"input_boolean.evcs_advisor_enabled\"),\n \"question\": states(\"input_text.evcs_conseiller_question\"),\n \"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,\n \"derniere_question_at\": state_attr(\"sensor.evcs_conseiller_reponse\", \"question_at\") | int(0),\n\n \"maintenant\": now().strftime(\"%d/%m %H:%M\"),\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"battery_current_a\": states(\"sensor.victron_battery_current\"),\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"grid_power_w\": states(\"sensor.victron_grid_power_l1\"),\n \"edf_contacteur\": states(\"switch.edf_general_ok_switch\"),\n \"session_hc\": states(\"input_boolean.victron_evcs_night_grid_session_committed\"),\n \"production_jour_kwh\": states(\"sensor.evcs_production_jour_kwh\"),\n \"import_jour_kwh\": states(\"sensor.evcs_import_jour_kwh\"),\n \"consommation_jour_kwh\": states(\"sensor.evcs_consommation_jour_kwh\"),\n \"vehicule_branche\": states(\"binary_sensor.victron_evcs_vehicle_connected\"),\n \"vehicule\": states(\"input_select.victron_evcs_vehicle\"),\n \"borne_puissance_w\": states(\"sensor.victron_evcs_ac_power\"),\n \"controleur_etat\": states(\"input_text.victron_evcs_surplus_state\"),\n \"controleur_source\": states(\"input_text.victron_evcs_source_mode\"),\n \"controleur_raison\": states(\"input_text.victron_evcs_surplus_reason\")[:250],\n \"strategie\": states(\"input_select.victron_evcs_strategy\"),\n \"mode_nuit\": states(\"input_select.victron_evcs_night_charge_mode\"),\n \"cible_matin\": states(\"input_number.victron_evcs_night_target_soc\"),\n \"acm_courant_max\": states(\"sensor.victron_max_charge_current_setting\"),\n \"bilan_raison\": states(\"input_text.evcs_balance_reason\"),\n \"bilan_requis\": states(\"input_number.evcs_balance_required_soc\"),\n \"chauffe_eau\": states(\"switch.tt_chauffe_eau_pleumeur_switch\"),\n \"chauffe_eau_decision\": states(\"input_text.victron_water_heater_advice_state\"),\n \"prevision_nuit\": state_attr(\"sensor.evcs_prevision_nuit\", \"texte_rapport\") or \"\",\n \"revue_jour_date\": states(\"sensor.evcs_revue_jour\"),\n \"revue_jour_texte\": state_attr(\"sensor.evcs_revue_jour\", \"texte\") or \"\",\n \"revue_pistes\": state_attr(\"sensor.evcs_advisor_revue\", \"pistes\") or []\n} | tojson }}\n"
|
|
}
|
|
]
|
|
},
|
|
"options": {
|
|
"timeout": 15000
|
|
}
|
|
},
|
|
"credentials": {
|
|
"httpBearerAuth": {
|
|
"id": "uv3sknYYigxBkaVN",
|
|
"name": "Bearer Auth account 2"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"name": "BUILD_PROMPT",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
400,
|
|
0
|
|
],
|
|
"parameters": {
|
|
"jsCode": "// =====================================================\n// CONSEILLER — DIALOGUE DANS HOME ASSISTANT (08/10/2026, proprietaire)\n// Le proprietaire ecrit une question dans input_text.evcs_conseiller_question ;\n// ce workflow (toutes les minutes) la voit, interroge le modele avec l'etat du\n// systeme, la revue du jour et ses pistes, et publie la reponse dans\n// sensor.evcs_conseiller_reponse, affichee sur la carte du conseiller.\n// Le conseiller EXPLIQUE, il ne commande rien (doctrine §79 : les chiffres\n// viennent des mesures transmises, jamais de lui).\n// =====================================================\n\nconst raw = $input.first().json;\nconst input = typeof raw.data === 'string' ? JSON.parse(raw.data) : (raw.data || raw);\nconst onOff = (v) => (v ?? 'off').toString().trim().toLowerCase() === 'on';\nconst txt = (v) => (v ?? '').toString().trim();\n\nconst question = txt(input.question);\nconst questionAt = Number(input.question_at) || 0;\nconst nouvelle = !!question && !['unknown', 'unavailable'].includes(question)\n && questionAt > (Number(input.derniere_question_at) || 0);\n\nlet pistes = input.revue_pistes;\nif (typeof pistes === 'string') { try { pistes = JSON.parse(pistes); } catch { pistes = []; } }\nif (!Array.isArray(pistes)) pistes = [];\n\nconst donnees = [\n `Maintenant : ${txt(input.maintenant)} (heure de Paris).`,\n `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.`,\n `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)}.`,\n `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.`,\n `Borne : vehicule branche ${txt(input.vehicule_branche)} (selecteur ${txt(input.vehicule)}), puissance ${txt(input.borne_puissance_w)} W.`,\n `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)}`,\n `Bilan energetique : SOC requis ${txt(input.bilan_requis)} %, cible du matin ${txt(input.cible_matin)} %. ${txt(input.bilan_raison)}`,\n `Chauffe-eau : ${txt(input.chauffe_eau)}, decision ${txt(input.chauffe_eau_decision)}.`,\n input.prevision_nuit ? `Prevision de la nuit : ${txt(input.prevision_nuit)}` : '',\n `Revue de la journee (${txt(input.revue_jour_date)}) : ${txt(input.revue_jour_texte) || 'pas encore publiee'}`,\n pistes.length ? `Pistes du conseiller pour cette revue : ${pistes.map((p) => `[${p.code}] ${p.piste}`).join(' ; ')}` : '',\n].filter(Boolean).join('\\n');\n\nconst systemPrompt = [\n \"Tu es le CONSEILLER du systeme energie d'une maison en Bretagne (16 kWc solaire,\",\n 'batterie ~23 kWh utiles soit 0,30 kWh par point de SOC, Victron ESS SANS injection,',\n 'abonnement EDF 12 kVA, contacteur EDF ouvert la plupart du temps, heures creuses',\n '01:24-07:24 et 12:24-14:24, borne pour trois vehicules : JEEP, VW, ZERO).',\n 'Le proprietaire te pose une question. Reponds en francais, clairement, en 1 500',\n 'caracteres au plus.',\n '',\n 'VOCABULAIRE (ne te trompe pas) :',\n '- contacteur EDF « on » = EDF FERME = la maison est raccordee au reseau ; « off » = EDF',\n ' ouvert = la maison vit sur batterie et solaire. Le proprietaire dit « EDF ferme » pour on.',\n '- ACM = « Auto Charge Manager », le workflow qui fixe le courant de charge maximal de la',\n ' batterie ; le controleur = celui de la borne ; le bilan = le calcul du SOC requis.',\n '- mode de nuit sans_edf : la voiture ne fait jamais fermer EDF a elle seule ; complete :',\n ' la voiture charge sur EDF en heures creuses.',\n '',\n 'Reponds d abord directement a la question, puis explique.',\n '',\n 'REGLES ABSOLUES :',\n '- Tu n utilises QUE les donnees ci-dessous. N invente aucun chiffre : si une valeur',\n ' manque, dis « je n ai pas cette donnee ».',\n '- Tu ne commandes rien et tu ne pretends pas avoir agi. Si une action semble utile,',\n ' dis laquelle et laisse le proprietaire decider.',\n '- Distingue ce qui est MESURE (les donnees) de ce que tu SUPPOSES (dis « je suppose »).',\n].join('\\n');\n\nreturn [{ json: {\n enabled: onOff(input.advisor_enabled) && nouvelle,\n messages: [\n { role: 'system', content: systemPrompt },\n { role: 'user', content: `DONNEES MESUREES :\\n${donnees}\\n\\nQUESTION : ${question}` },\n ],\n context: { question, questionAt, donnees },\n} }];\n"
|
|
}
|
|
},
|
|
{
|
|
"name": "IF_ENABLED",
|
|
"type": "n8n-nodes-base.if",
|
|
"typeVersion": 2.2,
|
|
"position": [
|
|
600,
|
|
0
|
|
],
|
|
"parameters": {
|
|
"conditions": {
|
|
"options": {
|
|
"caseSensitive": true,
|
|
"leftValue": "",
|
|
"typeValidation": "loose",
|
|
"version": 2
|
|
},
|
|
"combinator": "and",
|
|
"conditions": [
|
|
{
|
|
"leftValue": "={{ $json.enabled }}",
|
|
"rightValue": "",
|
|
"operator": {
|
|
"type": "boolean",
|
|
"operation": "true",
|
|
"singleValue": true
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"name": "LLM_LOCAL_MODELS",
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.2,
|
|
"position": [
|
|
800,
|
|
-100
|
|
],
|
|
"parameters": {
|
|
"method": "GET",
|
|
"url": "http://10.10.10.8:8000/v1/models",
|
|
"options": {
|
|
"timeout": 5000
|
|
}
|
|
},
|
|
"onError": "continueRegularOutput",
|
|
"alwaysOutputData": true
|
|
},
|
|
{
|
|
"name": "LLM_LOCAL",
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.2,
|
|
"position": [
|
|
1000,
|
|
-100
|
|
],
|
|
"parameters": {
|
|
"method": "POST",
|
|
"url": "http://10.10.10.8:8000/v1/chat/completions",
|
|
"sendHeaders": true,
|
|
"headerParameters": {
|
|
"parameters": [
|
|
{
|
|
"name": "Content-Type",
|
|
"value": "application/json"
|
|
}
|
|
]
|
|
},
|
|
"sendBody": true,
|
|
"specifyBody": "json",
|
|
"jsonBody": "={{ JSON.stringify({ model: $('LLM_LOCAL_MODELS').first().json.data[0].id, messages: $('BUILD_PROMPT').first().json.messages, temperature: 0.1, max_tokens: 6000 }) }}",
|
|
"options": {
|
|
"timeout": 120000
|
|
}
|
|
},
|
|
"onError": "continueRegularOutput",
|
|
"alwaysOutputData": true
|
|
},
|
|
{
|
|
"name": "IF_LOCAL_OK",
|
|
"type": "n8n-nodes-base.if",
|
|
"typeVersion": 2.2,
|
|
"position": [
|
|
1200,
|
|
-100
|
|
],
|
|
"parameters": {
|
|
"conditions": {
|
|
"options": {
|
|
"caseSensitive": true,
|
|
"leftValue": "",
|
|
"typeValidation": "loose",
|
|
"version": 2
|
|
},
|
|
"combinator": "and",
|
|
"conditions": [
|
|
{
|
|
"leftValue": "={{ $json.choices !== undefined && $json.choices.length > 0 }}",
|
|
"rightValue": "",
|
|
"operator": {
|
|
"type": "boolean",
|
|
"operation": "true",
|
|
"singleValue": true
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"name": "LLM_CLOUD",
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.2,
|
|
"position": [
|
|
1400,
|
|
40
|
|
],
|
|
"parameters": {
|
|
"method": "POST",
|
|
"url": "https://openrouter.ai/api/v1/chat/completions",
|
|
"authentication": "genericCredentialType",
|
|
"genericAuthType": "httpHeaderAuth",
|
|
"sendHeaders": true,
|
|
"headerParameters": {
|
|
"parameters": [
|
|
{
|
|
"name": "Content-Type",
|
|
"value": "application/json"
|
|
}
|
|
]
|
|
},
|
|
"sendBody": true,
|
|
"specifyBody": "json",
|
|
"jsonBody": "={{ JSON.stringify({ model: 'anthropic/claude-haiku-4.5', messages: $('BUILD_PROMPT').first().json.messages, temperature: 0.1, max_tokens: 1000 }) }}",
|
|
"options": {
|
|
"timeout": 60000
|
|
}
|
|
},
|
|
"credentials": {
|
|
"httpHeaderAuth": {
|
|
"id": "eOM9xofDHT0KMoeY",
|
|
"name": "OpenRouter Advisor"
|
|
}
|
|
},
|
|
"onError": "continueRegularOutput",
|
|
"alwaysOutputData": true
|
|
},
|
|
{
|
|
"name": "VALIDATE_PARSE",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
1600,
|
|
-30
|
|
],
|
|
"parameters": {
|
|
"jsCode": "// =====================================================\n// CONSEILLER — DIALOGUE : VALIDATE_PARSE (08/10/2026)\n// Reponse en texte libre. ANTI-INVENTION : les nombres de la reponse absents des\n// donnees transmises (et de la question) sont SIGNALES en tete de reponse, pas\n// caches : le proprietaire juge.\n// =====================================================\n\nconst resp = $input.first().json;\nconst context = $('BUILD_PROMPT').first().json.context;\nconst viaCloud = (() => { try { return $('LLM_CLOUD').isExecuted === true; } catch { return false; } })()\n || typeof resp?.provider === 'string' || /^gen-/.test(String(resp?.id || ''));\nconst source = viaCloud ? 'openrouter' : 'local';\nconst modelUsed = (resp?.model || 'aucun').toString().slice(0, 60);\n\nconst content = resp?.choices?.[0]?.message?.content;\nif (typeof content !== 'string' || !content.trim()) {\n return [{ json: { status: 'llm_offline', reponse: null, avertissement: '', source, modelUsed, context } }];\n}\nconst reponse = content.replace(/<think>[\\s\\S]*?<\\/think>/gi, '').replace(/```[a-z]*/gi, '').trim().slice(0, 3000);\n\nconst nombres = (s) => (String(s).match(/\\d+(?:[.,]\\d+)?/g) || []).map((x) => String(Number(x.replace(',', '.'))));\n// Une mesure publiee 1.2000000298 est citee « 1,2 » : on admet ses arrondis.\nconst permis = new Set(nombres(`${context.donnees} ${context.question}`)\n .flatMap((n) => [n, String(Math.round(Number(n))), String(Math.round(Number(n) * 10) / 10), String(Math.round(Number(n) * 100) / 100)]));\n// Les petits entiers (1 a 3) servent aux enumerations ; ils ne sont pas des mesures.\nconst inventes = [...new Set(nombres(reponse).filter((n) => !permis.has(n) && !['0', '1', '2', '3'].includes(n)))];\nconst avertissement = inventes.length\n ? `Attention : chiffre(s) absent(s) des donnees transmises : ${inventes.join(', ')}. A verifier.`\n : '';\n\nreturn [{ json: { status: 'ok', reponse, avertissement, inventes, source, modelUsed, context } }];\n"
|
|
}
|
|
},
|
|
{
|
|
"name": "BUILD_HA_CALLS",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
1800,
|
|
-30
|
|
],
|
|
"parameters": {
|
|
"jsCode": "// =====================================================\n// CONSEILLER — DIALOGUE : BUILD_HA_CALLS (08/10/2026)\n// GARANTIE : n'ecrit QUE sensor.evcs_conseiller_reponse (via /api/states) et\n// input_text.evcs_advisor_status. AUCUN switch, AUCUNE strategie, AUCUNE\n// commande. Ne pas ajouter d'autres entites ici.\n// =====================================================\n\nconst { status, reponse, avertissement, inventes, source, modelUsed, context } = $input.first().json;\nconst stamp = new Date().toLocaleString('fr-FR', { timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });\n\nreturn [{ json: { state_entity: 'sensor.evcs_conseiller_reponse', data: {\n state: stamp,\n attributes: {\n friendly_name: 'Reponse du conseiller',\n question: context.question,\n question_at: context.questionAt,\n statut: status,\n reponse: reponse || (status === 'llm_offline' ? 'Le modele n a pas repondu (Spark et OpenRouter hors ligne). Reessaie en reecrivant la question.' : ''),\n avertissement: avertissement || '',\n chiffres_non_trouves: inventes || [],\n modele: modelUsed,\n source,\n source_texte: source === 'openrouter'\n ? 'Repli OpenRouter (cloud) : le modele local de la Spark n a pas repondu, la question et les donnees ont ete envoyees a OpenRouter.'\n : 'Modele local de la Spark : rien n est sorti de la maison.',\n },\n} } }];\n"
|
|
}
|
|
},
|
|
{
|
|
"name": "Execute HA Dialogue",
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.2,
|
|
"position": [
|
|
2000,
|
|
-30
|
|
],
|
|
"parameters": {
|
|
"method": "POST",
|
|
"url": "={{ $json.state_entity ? 'http://10.10.10.17:8123/api/states/' + $json.state_entity : 'http://10.10.10.17:8123/api/services/' + $json.domain + '/' + $json.service }}",
|
|
"authentication": "genericCredentialType",
|
|
"genericAuthType": "httpBearerAuth",
|
|
"sendHeaders": true,
|
|
"headerParameters": {
|
|
"parameters": [
|
|
{
|
|
"name": "Content-Type",
|
|
"value": "application/json"
|
|
},
|
|
{
|
|
"name": "accept",
|
|
"value": "application/json"
|
|
}
|
|
]
|
|
},
|
|
"sendBody": true,
|
|
"specifyBody": "json",
|
|
"jsonBody": "={{ $json.data }}",
|
|
"options": {
|
|
"timeout": 15000
|
|
}
|
|
},
|
|
"credentials": {
|
|
"httpBearerAuth": {
|
|
"id": "uv3sknYYigxBkaVN",
|
|
"name": "Bearer Auth account 2"
|
|
}
|
|
},
|
|
"onError": "continueRegularOutput"
|
|
}
|
|
],
|
|
"connections": {
|
|
"Schedule Trigger": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "HA Snapshot Dialogue",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"HA Snapshot Dialogue": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "BUILD_PROMPT",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"BUILD_PROMPT": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "IF_ENABLED",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"IF_ENABLED": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "LLM_LOCAL_MODELS",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[]
|
|
]
|
|
},
|
|
"LLM_LOCAL_MODELS": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "LLM_LOCAL",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"LLM_LOCAL": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "IF_LOCAL_OK",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"IF_LOCAL_OK": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "VALIDATE_PARSE",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "LLM_CLOUD",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"LLM_CLOUD": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "VALIDATE_PARSE",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"VALIDATE_PARSE": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "BUILD_HA_CALLS",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"BUILD_HA_CALLS": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Execute HA Dialogue",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
}
|
|
},
|
|
"settings": {
|
|
"executionOrder": "v1",
|
|
"callerPolicy": "workflowsFromSameOwner"
|
|
},
|
|
"staticData": null,
|
|
"id": "zs7j7jvi6Q7dGDrJ"
|
|
}
|