Revue : pistes ecartees et source (local/OpenRouter) gardees, historisation pour rejeu, carte verbeuse du conseiller
This commit is contained in:
14
HANDOFF.md
14
HANDOFF.md
@@ -2176,5 +2176,17 @@ Le conseiller (observation toutes les 15 min, 1 500 « rien » / 871 signalement
|
|||||||
- **A verifier ce matin** : 07:30 `sensor.evcs_revue_jour`, ~07:30-07:45 reponse du conseiller (pistes retenues /
|
- **A verifier ce matin** : 07:30 `sensor.evcs_revue_jour`, ~07:30-07:45 reponse du conseiller (pistes retenues /
|
||||||
ecartees), 07:45 courriel. Retour arriere : `git revert` + push du conseiller + redemarrage du service.
|
ecartees), 07:45 courriel. Retour arriere : `git revert` + push du conseiller + redemarrage du service.
|
||||||
|
|
||||||
|
- **Suite (~05:50, proprietaire)** : pistes ECARTEES gardees avec leur motif (`ecartees` : chiffre absent du
|
||||||
|
constat, code inconnu, doublon) ; **qui a repondu** affiche (`source` local | openrouter, `source_texte` ; le
|
||||||
|
repli OpenRouter est garde, son role est dit sur la carte et dans le courriel) ; **historisation** : tables
|
||||||
|
`revue_constat` (jour, code, gravite, debut, fin, texte, contexte, chiffres) et `revue_piste` (jour, code,
|
||||||
|
piste, retenue, motif, modele, source, `avis_proprietaire` a remplir) pour REJOUER le conseiller plus tard
|
||||||
|
(regression) avant de lui donner un role actif ; `sensor.evcs_revue_historique` (7 jours). Tests conseiller
|
||||||
|
27/27. **Carte EVCS Master, onglet Session** : « Revue de ce matin » (chaque constat, la raison du controleur a
|
||||||
|
ce moment-la, la piste et les pistes ecartees avec leur motif), « Qui a repondu, et comment » (local / OpenRouter
|
||||||
|
et son role), « Les 7 derniers jours », « Dialoguer » (via OpenClaw pour l'instant). Rendu verifie par
|
||||||
|
`/api/template` avec deux capteurs fictifs dates du 07/10, supprimes ensuite. Sauvegarde
|
||||||
|
`evcs_master.yaml.bak-20261008-conseiller`.
|
||||||
|
|
||||||
---
|
---
|
||||||
*Document mis a jour le : 08/10/2026 (~05:35) — conseiller relecteur de la journee (revue calculee + pistes) ; nuit : voiture qui profite d'EDF ferme pour la batterie, nuit qui prepare le soir les jours annonces faibles ; 08/10/2026 (~00:15) — jour faible : charge maximale en HC de midi (bilan 100 %, ACM plafond + solaire) ; 07/10/2026 (~22:45) — reprise de 16 h verifiee, EDF en HP a 18:03, bilan : reprise du soir et prevision recalee sur la matinee ; bilan HC de midi, reprise du soir apres coupure en pleine chauffe, mesure ECS sur 10 min ; recharge reseau HC : courant calcule (12 kVA) et consigne non tenue ; HC de midi : AUTO ne chauffe pas (BOOST), alerte v2, Jeep (contact, relecture, zone), recharge batterie bridee a 30 A par l'ACM ; jour faible = EDF les deux fenetres HC pour le chauffe-eau, alerte BOOST, Victron Mobile refait ; 1re decision jour faible en reel, mesure ECS reparee, douches 06:00/20:00, Grafana chauffe-eau, tableaux ECS ; chauffe-eau en HC de nuit les jours faibles (decision du bilan, workflow ECS sous controle du depot, VRM, V2.3 desactive) ; garde batterie reseau : voiture bridee a 6 A au lieu d'un arret ; garde-fou concierge 2 h ; allers-retours brefs de la borne retrouves dans l'historique HA (echange VW -> JEEP de 20 s), production 14,5 kWh contre 22 prevus, defaut garde batterie en mode reseau a trancher ; voiture a midi : la batterie n'est plus comptee comme du solaire (EDF automatique les jours de depart si le surplus ne couvre pas) ; nuit 05->06 : JEEP chargee sur EDF en complete (6,32 kWh), budget 0 du a la prevision, Windy/ECMWF contre VRM, a comparer ce soir ; concierge aligne sur la preuve fin de trajet ; a verifier : nuit 05->06 et mardi faible, comparaison VRM/Forecast.Solar ; JEEP identifiee aussi par sa fin de trajet, prevision de la nuit 05->06 ; identification JEEP reussie en reel, correction humaine qui prime (refus supprime), session du 04/10 coupee ; EVCS Master en quatre onglets ; Jeep lue par Uconnect et identifiee seule, capacite JEEP 10 kWh, concierge variante (a) ; releve par photo (SPARK), hass-uconnect en attente ; ecart semaine/week-end mesure (pas de profil week-end la nuit) ; nuit du budget : budget borne au plancher prudent, sans EDF = voiture bloquee meme EDF ferme (batterie seule) ; Energie : sauts corriges ; tableau Energie complete (Sud/Ouest, batterie par integration, borne ; aucun redemarrage HA) ; kilometrage des vehicules (km/kWh, capacite, releves de reference, agent cable 15/15) ; a verifier le 04/10 au matin : nuit du budget JEEP ; concierge : vehicule non confirme demande, heure de saisie ; Grafana vehicules en tableau ; Grafana vehicules : ZERO et Invite ajoutes, session 16:46 reetiquetee ZERO ; prevision SOC 10 h ~35 % ; concierge fiable : niveau note par le script, une ligne, dedoublonnage, simulation par drapeau ; agent : message incompris = question, bourse sur demande explicite (banc final 6/6) ; a verifier : nuit du samedi 03 au dimanche 04/10 (budget < besoin JEEP), cron orange-cloture lundi 05/10 apres 17:40, comparaison VRM / Forecast.Solar lundi 05/10*
|
*Document mis a jour le : 08/10/2026 (~05:55) — conseiller relecteur de la journee (revue calculee + pistes) ; nuit : voiture qui profite d'EDF ferme pour la batterie, nuit qui prepare le soir les jours annonces faibles ; 08/10/2026 (~00:15) — jour faible : charge maximale en HC de midi (bilan 100 %, ACM plafond + solaire) ; 07/10/2026 (~22:45) — reprise de 16 h verifiee, EDF en HP a 18:03, bilan : reprise du soir et prevision recalee sur la matinee ; bilan HC de midi, reprise du soir apres coupure en pleine chauffe, mesure ECS sur 10 min ; recharge reseau HC : courant calcule (12 kVA) et consigne non tenue ; HC de midi : AUTO ne chauffe pas (BOOST), alerte v2, Jeep (contact, relecture, zone), recharge batterie bridee a 30 A par l'ACM ; jour faible = EDF les deux fenetres HC pour le chauffe-eau, alerte BOOST, Victron Mobile refait ; 1re decision jour faible en reel, mesure ECS reparee, douches 06:00/20:00, Grafana chauffe-eau, tableaux ECS ; chauffe-eau en HC de nuit les jours faibles (decision du bilan, workflow ECS sous controle du depot, VRM, V2.3 desactive) ; garde batterie reseau : voiture bridee a 6 A au lieu d'un arret ; garde-fou concierge 2 h ; allers-retours brefs de la borne retrouves dans l'historique HA (echange VW -> JEEP de 20 s), production 14,5 kWh contre 22 prevus, defaut garde batterie en mode reseau a trancher ; voiture a midi : la batterie n'est plus comptee comme du solaire (EDF automatique les jours de depart si le surplus ne couvre pas) ; nuit 05->06 : JEEP chargee sur EDF en complete (6,32 kWh), budget 0 du a la prevision, Windy/ECMWF contre VRM, a comparer ce soir ; concierge aligne sur la preuve fin de trajet ; a verifier : nuit 05->06 et mardi faible, comparaison VRM/Forecast.Solar ; JEEP identifiee aussi par sa fin de trajet, prevision de la nuit 05->06 ; identification JEEP reussie en reel, correction humaine qui prime (refus supprime), session du 04/10 coupee ; EVCS Master en quatre onglets ; Jeep lue par Uconnect et identifiee seule, capacite JEEP 10 kWh, concierge variante (a) ; releve par photo (SPARK), hass-uconnect en attente ; ecart semaine/week-end mesure (pas de profil week-end la nuit) ; nuit du budget : budget borne au plancher prudent, sans EDF = voiture bloquee meme EDF ferme (batterie seule) ; Energie : sauts corriges ; tableau Energie complete (Sud/Ouest, batterie par integration, borne ; aucun redemarrage HA) ; kilometrage des vehicules (km/kWh, capacite, releves de reference, agent cable 15/15) ; a verifier le 04/10 au matin : nuit du budget JEEP ; concierge : vehicule non confirme demande, heure de saisie ; Grafana vehicules en tableau ; Grafana vehicules : ZERO et Invite ajoutes, session 16:46 reetiquetee ZERO ; prevision SOC 10 h ~35 % ; concierge fiable : niveau note par le script, une ligne, dedoublonnage, simulation par drapeau ; agent : message incompris = question, bourse sur demande explicite (banc final 6/6) ; a verifier : nuit du samedi 03 au dimanche 04/10 (budget < besoin JEEP), cron orange-cloture lundi 05/10 apres 17:40, comparaison VRM / Forecast.Solar lundi 05/10*
|
||||||
|
|||||||
@@ -198,36 +198,84 @@ views:
|
|||||||
- type: vertical-stack
|
- type: vertical-stack
|
||||||
cards:
|
cards:
|
||||||
- type: custom:mushroom-title-card
|
- type: custom:mushroom-title-card
|
||||||
title: 🤖 Conseiller IA (observateur)
|
title: 🤖 Conseiller IA — relecteur de la journée
|
||||||
subtitle: Il regarde et signale — il ne décide rien
|
subtitle: Les constats sont mesurés par le code ; le conseiller propose des pistes, il ne décide rien
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
title: Revue de ce matin
|
||||||
|
content: |
|
||||||
|
{% set r = states.sensor.evcs_revue_jour %}
|
||||||
|
{% set a = states.sensor.evcs_advisor_revue %}
|
||||||
|
{% set auj = now().strftime('%Y-%m-%d') %}
|
||||||
|
{% if r is none %}
|
||||||
|
**Revue indisponible** (capteur absent : publiée à 07:30 par le service d'historisation).
|
||||||
|
{% else %}
|
||||||
|
**Revue du {{ r.state }}**{% if r.state != auj %} — ⚠️ pas celle d'aujourd'hui (publiée à 07:30){% endif %}
|
||||||
|
· 24 h jusqu'à la fin des heures creuses de nuit · **{{ r.attributes.a_examiner | default(0) }} point(s) à examiner**
|
||||||
|
|
||||||
|
{% for c in r.attributes.constats | default([]) %}
|
||||||
|
---
|
||||||
|
{% if c.gravite == 'a_examiner' %}🔶{% else %}ℹ️{% endif %} **{{ c.code }}**
|
||||||
|
{{ c.texte }}
|
||||||
|
{% if c.contexte %}<br><small>Raison publiée par le contrôleur à ce moment-là : <i>{{ c.contexte }}</i></small>{% endif %}
|
||||||
|
{% set ps = (a.attributes.pistes | default([]) | selectattr('code','eq',c.code) | list) if a is not none and a.state == r.state else [] %}
|
||||||
|
{% for p in ps %}
|
||||||
|
<br>💡 **Piste du conseiller** (avis, à vérifier) : {{ p.piste }}
|
||||||
|
{% endfor %}
|
||||||
|
{% set es = (a.attributes.ecartees | default([]) | selectattr('code','eq',c.code) | list) if a is not none and a.state == r.state else [] %}
|
||||||
|
{% for e in es %}
|
||||||
|
<br><small>🚫 Piste écartée ({{ e.motif }}) : <i>{{ e.piste }}</i></small>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if (r.attributes.constats | default([])) | length == 0 %}✅ {{ r.attributes.texte }}{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
title: Qui a répondu, et comment
|
||||||
|
content: |
|
||||||
|
{% set a = states.sensor.evcs_advisor_revue %}
|
||||||
|
{% if a is none %}
|
||||||
|
Le conseiller n'a encore publié aucune revue.
|
||||||
|
{% else %}
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Revue relue | {{ a.state }} ({{ a.attributes.constats_relus | default('?') }} constat(s)) |
|
||||||
|
| Modèle | {{ a.attributes.modele | default('?') }} |
|
||||||
|
| **Où** | {% if a.attributes.source == 'openrouter' %}☁️ **OpenRouter (cloud)**{% else %}🏠 **Spark (local)**{% endif %} |
|
||||||
|
| Pistes retenues | {{ a.attributes.pistes | default([]) | length }} |
|
||||||
|
| Pistes écartées | {{ a.attributes.rejetees | default(0) }} (chiffre inventé, code inconnu, doublon) |
|
||||||
|
|
||||||
|
{{ a.attributes.source_texte | default('') }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
**Rôle d'OpenRouter** : solution de **secours seulement**. Le conseiller interroge d'abord le modèle local de la Spark ; si la Spark ne répond pas, les constats (textes ci-dessus, sans identifiants) sont envoyés à OpenRouter. Il n'a aucun autre rôle.
|
||||||
|
|
||||||
|
Statut du dernier passage : `{{ states('input_text.evcs_advisor_status') }}` · {{ states('input_text.evcs_advisor_last_run') }} · {{ states('input_text.evcs_advisor_model') }}
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
title: Les 7 derniers jours
|
||||||
|
content: |
|
||||||
|
{% set h = states.sensor.evcs_revue_historique %}
|
||||||
|
{% if h is none or (h.attributes.jours | default([])) | length == 0 %}
|
||||||
|
Pas encore d'historique (la première revue est publiée le 08/10 à 07:30).
|
||||||
|
{% else %}
|
||||||
|
{% for j in h.attributes.jours %}
|
||||||
|
**{{ j.jour }}** — {{ j.constats | selectattr('gravite','eq','a_examiner') | list | length }} point(s), {{ j.pistes | selectattr('retenue') | list | length }} piste(s) retenue(s), {{ j.pistes | rejectattr('retenue') | list | length }} écartée(s){% if j.pistes | selectattr('source','eq','openrouter') | list | length > 0 %} · ☁️ OpenRouter{% endif %}
|
||||||
|
{% for c in j.constats %}
|
||||||
|
<br><small>• {{ c.texte }}</small>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
<small>Tout est gardé en base (`revue_constat`, `revue_piste`, avec les pistes écartées et leur motif) pour rejouer le conseiller plus tard et mesurer s'il progresse.</small>
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
title: Dialoguer avec le conseiller
|
||||||
|
content: |
|
||||||
|
Pour l'instant, le dialogue passe par **OpenClaw** (WhatsApp ou vocal) : « qu'est-ce qui a cloché hier ? » — il lit la revue et les pistes ci-dessus, et présente les pistes comme des avis.
|
||||||
|
|
||||||
- type: entities
|
- type: entities
|
||||||
title: Dernière observation
|
|
||||||
entities:
|
entities:
|
||||||
- entity: input_text.evcs_advisor_gravite
|
|
||||||
name: Gravité
|
|
||||||
icon: mdi:alert-circle-outline
|
|
||||||
- entity: input_text.evcs_advisor_constat
|
|
||||||
name: Ce qu'il voit
|
|
||||||
icon: mdi:eye-outline
|
|
||||||
- entity: input_text.evcs_advisor_ce_qui_cloche
|
|
||||||
name: Ce qui cloche
|
|
||||||
icon: mdi:help-rhombus-outline
|
|
||||||
- entity: input_text.evcs_advisor_reason
|
|
||||||
name: Piste (et sa confiance)
|
|
||||||
icon: mdi:magnify
|
|
||||||
- type: divider
|
|
||||||
- entity: input_text.evcs_advisor_status
|
|
||||||
name: Statut
|
|
||||||
- entity: input_text.evcs_advisor_model
|
|
||||||
name: Modèle LLM
|
|
||||||
- entity: input_text.evcs_advisor_last_run
|
|
||||||
name: Dernière analyse
|
|
||||||
- entity: input_number.evcs_advisor_agree_count
|
|
||||||
name: Cycles calmes
|
|
||||||
- entity: input_number.evcs_advisor_diverge_count
|
|
||||||
name: Signalements
|
|
||||||
- type: divider
|
|
||||||
- entity: input_boolean.evcs_advisor_enabled
|
- entity: input_boolean.evcs_advisor_enabled
|
||||||
name: Conseiller actif (kill-switch)
|
name: Conseiller actif (kill-switch)
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
// les autres a /api/services/<domain>/<service>.
|
// les autres a /api/services/<domain>/<service>.
|
||||||
// =====================================================
|
// =====================================================
|
||||||
|
|
||||||
const { status, detail, pistes, rejetees, modelUsed, context } = $input.first().json;
|
const { status, detail, pistes, rejetees, ecartees, source, modelUsed, context } = $input.first().json;
|
||||||
|
|
||||||
function stampParis() {
|
function stampParis() {
|
||||||
const p = new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit',
|
const p = new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit',
|
||||||
@@ -22,12 +22,17 @@ const setText = (entity, value) => push('input_text', 'set_value', { entity_id:
|
|||||||
|
|
||||||
setText('input_text.evcs_advisor_status', status + (detail ? `: ${detail}` : ''));
|
setText('input_text.evcs_advisor_status', status + (detail ? `: ${detail}` : ''));
|
||||||
setText('input_text.evcs_advisor_last_run', stampParis());
|
setText('input_text.evcs_advisor_last_run', stampParis());
|
||||||
setText('input_text.evcs_advisor_model', modelUsed);
|
setText('input_text.evcs_advisor_model', `${modelUsed} (${source === 'openrouter' ? 'OpenRouter, cloud' : 'local Spark'})`);
|
||||||
|
|
||||||
if (status === 'ok' && Array.isArray(pistes)) {
|
if (status === 'ok' && Array.isArray(pistes)) {
|
||||||
calls.push({ json: { state_entity: 'sensor.evcs_advisor_revue', data: {
|
calls.push({ json: { state_entity: 'sensor.evcs_advisor_revue', data: {
|
||||||
state: context.jour,
|
state: context.jour,
|
||||||
attributes: { friendly_name: 'Revue du conseiller IA', pistes, rejetees, modele: modelUsed,
|
attributes: { friendly_name: 'Revue du conseiller IA', pistes, rejetees, ecartees: ecartees || [], modele: modelUsed,
|
||||||
|
source,
|
||||||
|
source_texte: source === 'openrouter'
|
||||||
|
? 'Repli OpenRouter (cloud) : le modele local de la Spark n a pas repondu, les constats ont ete envoyes a OpenRouter.'
|
||||||
|
: 'Modele local de la Spark : rien n est sorti de la maison.',
|
||||||
|
constats_relus: context.constats.length,
|
||||||
avertissement: 'Avis a verifier, pas des faits. Les constats chiffres sont dans sensor.evcs_revue_jour.' },
|
avertissement: 'Avis a verifier, pas des faits. Les constats chiffres sont dans sensor.evcs_revue_jour.' },
|
||||||
} } });
|
} } });
|
||||||
setText('input_text.evcs_advisor_reason',
|
setText('input_text.evcs_advisor_reason',
|
||||||
|
|||||||
@@ -10,9 +10,16 @@
|
|||||||
const resp = $input.first().json;
|
const resp = $input.first().json;
|
||||||
const context = $('BUILD_PROMPT').first().json.context;
|
const context = $('BUILD_PROMPT').first().json.context;
|
||||||
|
|
||||||
|
// QUI A REPONDU (08/10, proprietaire : « OpenRouter, garde-le mais affiche-le
|
||||||
|
// clairement »). Le repli cloud n'est appele que si le modele local de la Spark
|
||||||
|
// echoue ; dans ce cas les constats ont quitte la maison et il faut le dire.
|
||||||
|
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';
|
||||||
|
|
||||||
function fail(status, detail) {
|
function fail(status, detail) {
|
||||||
return [{ json: { status, detail: (detail || '').toString().slice(0, 200), pistes: null, rejetees: 0,
|
return [{ json: { status, detail: (detail || '').toString().slice(0, 200), pistes: null, rejetees: 0, ecartees: [],
|
||||||
modelUsed: (resp?.model || 'aucun').toString().slice(0, 60), context } }];
|
source, modelUsed: (resp?.model || 'aucun').toString().slice(0, 60), context } }];
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = resp?.choices?.[0]?.message?.content;
|
const content = resp?.choices?.[0]?.message?.content;
|
||||||
@@ -36,18 +43,23 @@ if (!Array.isArray(obj.pistes)) return fail('invalid', 'champ pistes absent');
|
|||||||
const nombres = (s) => (String(s).match(/\d+(?:[.,]\d+)?/g) || []).map((x) => String(Number(x.replace(',', '.'))));
|
const nombres = (s) => (String(s).match(/\d+(?:[.,]\d+)?/g) || []).map((x) => String(Number(x.replace(',', '.'))));
|
||||||
|
|
||||||
const parCode = new Map(context.constats.map((c) => [c.code, c]));
|
const parCode = new Map(context.constats.map((c) => [c.code, c]));
|
||||||
|
// Les pistes ECARTEES sont gardees avec leur motif : elles sont historisees
|
||||||
|
// pour rejouer le conseiller plus tard (regression) et juger s'il progresse.
|
||||||
const pistes = [];
|
const pistes = [];
|
||||||
let rejetees = 0;
|
const ecartees = [];
|
||||||
|
const ecarter = (code, piste, motif) => ecartees.push({ code, piste: piste.slice(0, 280), motif });
|
||||||
for (const p of obj.pistes) {
|
for (const p of obj.pistes) {
|
||||||
const code = (p?.code ?? '').toString().trim();
|
const code = (p?.code ?? '').toString().trim();
|
||||||
const piste = (p?.piste ?? '').toString().trim();
|
const piste = (p?.piste ?? '').toString().trim();
|
||||||
const c = parCode.get(code);
|
const c = parCode.get(code);
|
||||||
if (!c || !piste) { rejetees++; continue; }
|
if (!piste) { ecarter(code, '', 'piste vide'); continue; }
|
||||||
|
if (!c) { ecarter(code, piste, 'code inconnu ou constat non a examiner'); continue; }
|
||||||
const permis = new Set(nombres(`${c.texte} ${c.contexte}`));
|
const permis = new Set(nombres(`${c.texte} ${c.contexte}`));
|
||||||
if (nombres(piste).some((n) => !permis.has(n))) { rejetees++; continue; }
|
const inventes = nombres(piste).filter((n) => !permis.has(n));
|
||||||
if (pistes.some((x) => x.code === code)) { rejetees++; continue; }
|
if (inventes.length) { ecarter(code, piste, `chiffre absent du constat : ${inventes.join(', ')}`); continue; }
|
||||||
|
if (pistes.some((x) => x.code === code)) { ecarter(code, piste, 'doublon'); continue; }
|
||||||
pistes.push({ code, piste: piste.slice(0, 280) });
|
pistes.push({ code, piste: piste.slice(0, 280) });
|
||||||
}
|
}
|
||||||
|
|
||||||
return [{ json: { status: 'ok', detail: '', pistes, rejetees,
|
return [{ json: { status: 'ok', detail: '', pistes, rejetees: ecartees.length, ecartees, source,
|
||||||
modelUsed: (resp.model || 'inconnu').toString().slice(0, 60), context } }];
|
modelUsed: (resp.model || 'inconnu').toString().slice(0, 60), context } }];
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ check('VALIDATE: un constat info n est pas relu', v({ pistes: [{ code: 'charge_m
|
|||||||
check('VALIDATE: doublon de code -> un seul garde', v({ pistes: [bon.pistes[0], bon.pistes[0]] }).pistes.length === 1);
|
check('VALIDATE: doublon de code -> un seul garde', v({ pistes: [bon.pistes[0], bon.pistes[0]] }).pistes.length === 1);
|
||||||
check('VALIDATE: texte sans JSON -> invalid', v('Je pense que tout va bien.').status === 'invalid');
|
check('VALIDATE: texte sans JSON -> invalid', v('Je pense que tout va bien.').status === 'invalid');
|
||||||
check('VALIDATE: pistes absent -> invalid', v({ constat: 'x' }).status === 'invalid');
|
check('VALIDATE: pistes absent -> invalid', v({ constat: 'x' }).status === 'invalid');
|
||||||
|
{ const o = v({ pistes: [{ code: 'edf_heures_pleines', piste: 'Il aurait fallu 60 % a 14:24.' }, bon.pistes[0]] });
|
||||||
|
check('VALIDATE: piste ecartee gardee avec son motif (historisation)', o.ecartees.length === 1 && /chiffre absent du constat : 60/.test(o.ecartees[0].motif) && o.pistes.length === 1, JSON.stringify(o.ecartees)); }
|
||||||
|
check('VALIDATE: modele local -> source local', o1.source === 'local');
|
||||||
|
check('VALIDATE: reponse OpenRouter -> source openrouter', run(VALIDATE_PARSE, { ...rep(bon), id: 'gen-123', provider: 'x' }, ctxNode)[0].json.source === 'openrouter');
|
||||||
check('VALIDATE: LLM hors ligne -> llm_offline', run(VALIDATE_PARSE, { error: 'ECONNREFUSED' }, ctxNode)[0].json.status === 'llm_offline');
|
check('VALIDATE: LLM hors ligne -> llm_offline', run(VALIDATE_PARSE, { error: 'ECONNREFUSED' }, ctxNode)[0].json.status === 'llm_offline');
|
||||||
|
|
||||||
// ---------- BUILD_HA_CALLS : garantie de perimetre ----------
|
// ---------- BUILD_HA_CALLS : garantie de perimetre ----------
|
||||||
@@ -82,6 +86,7 @@ const permis = (c) => c.domain === 'logbook' || c.state_entity === 'sensor.evcs_
|
|||||||
check('PERIMETRE: aucune ecriture hors entites advisor / logbook', calls.every(permis), JSON.stringify(calls.filter((c) => !permis(c))));
|
check('PERIMETRE: aucune ecriture hors entites advisor / logbook', calls.every(permis), JSON.stringify(calls.filter((c) => !permis(c))));
|
||||||
const capteur = calls.find((c) => c.state_entity === 'sensor.evcs_advisor_revue');
|
const capteur = calls.find((c) => c.state_entity === 'sensor.evcs_advisor_revue');
|
||||||
check('PERIMETRE: capteur de la revue publie, date du jour, pistes en attribut', !!capteur && capteur.data.state === '2026-10-08' && capteur.data.attributes.pistes.length === 2);
|
check('PERIMETRE: capteur de la revue publie, date du jour, pistes en attribut', !!capteur && capteur.data.state === '2026-10-08' && capteur.data.attributes.pistes.length === 2);
|
||||||
|
check('PERIMETRE: le role d OpenRouter est affiche', /local de la Spark/.test(capteur?.data.attributes.source_texte || ''));
|
||||||
const hl = run(BUILD_HA_CALLS, run(VALIDATE_PARSE, { error: 'x' }, ctxNode)[0].json, ctxNode).map((i) => i.json);
|
const hl = run(BUILD_HA_CALLS, run(VALIDATE_PARSE, { error: 'x' }, ctxNode)[0].json, ctxNode).map((i) => i.json);
|
||||||
check('PERIMETRE: hors ligne -> statut ecrit, aucun capteur (relance au cycle suivant)', !hl.some((c) => c.state_entity) && hl.some((c) => String(c.data?.value || '').startsWith('llm_offline')));
|
check('PERIMETRE: hors ligne -> statut ecrit, aucun capteur (relance au cycle suivant)', !hl.some((c) => c.state_entity) && hl.some((c) => String(c.data?.value || '').startsWith('llm_offline')));
|
||||||
|
|
||||||
|
|||||||
@@ -779,6 +779,36 @@ async function migrer(client) {
|
|||||||
// proprietaire. NULL pour les releves anterieurs dont le pack n'est pas sur.
|
// proprietaire. NULL pour les releves anterieurs dont le pack n'est pas sur.
|
||||||
await client.query('ALTER TABLE cellule ADD COLUMN IF NOT EXISTS pack smallint');
|
await client.query('ALTER TABLE cellule ADD COLUMN IF NOT EXISTS pack smallint');
|
||||||
|
|
||||||
|
// REVUE DE LA JOURNEE (08/10/2026) : constats calcules et pistes du
|
||||||
|
// conseiller, gardes pour REJOUER le conseiller plus tard (regression) et
|
||||||
|
// juger s'il merite un role actif. Les pistes ecartees le sont avec leur
|
||||||
|
// motif ; `avis_proprietaire` recevra le verdict humain (utile / a cote).
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS revue_constat (
|
||||||
|
jour date NOT NULL,
|
||||||
|
code text NOT NULL,
|
||||||
|
gravite text,
|
||||||
|
debut timestamptz,
|
||||||
|
fin timestamptz,
|
||||||
|
texte text,
|
||||||
|
contexte text,
|
||||||
|
chiffres jsonb,
|
||||||
|
PRIMARY KEY (jour, code, debut)
|
||||||
|
)`);
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS revue_piste (
|
||||||
|
jour date NOT NULL,
|
||||||
|
code text NOT NULL,
|
||||||
|
piste text NOT NULL,
|
||||||
|
retenue boolean NOT NULL,
|
||||||
|
motif text,
|
||||||
|
modele text,
|
||||||
|
source text,
|
||||||
|
avis_proprietaire text,
|
||||||
|
ts timestamptz DEFAULT now(),
|
||||||
|
PRIMARY KEY (jour, code, piste)
|
||||||
|
)`);
|
||||||
|
|
||||||
// Ce que le CONSEILLER a observe. Ses helpers sont ecrases toutes les
|
// Ce que le CONSEILLER a observe. Ses helpers sont ecrases toutes les
|
||||||
// quinze minutes : sans cette table, un signalement grave emis a 3 h du
|
// quinze minutes : sans cette table, un signalement grave emis a 3 h du
|
||||||
// matin a disparu au reveil. C'est precisement la trace que le proprietaire
|
// matin a disparu au reveil. C'est precisement la trace que le proprietaire
|
||||||
@@ -1147,6 +1177,7 @@ async function dejaFait(client, genre, jour) {
|
|||||||
}
|
}
|
||||||
async function revueDeLaJournee(sql, client, st) {
|
async function revueDeLaJournee(sql, client, st) {
|
||||||
if (!client) return 0;
|
if (!client) return 0;
|
||||||
|
if (!revueHistoriquePubliee) await publierRevueHistorique(client);
|
||||||
const { min, jour } = partsParis();
|
const { min, jour } = partsParis();
|
||||||
if (min < REVUE_PUBLIEE || min > 10 * 60) return 0;
|
if (min < REVUE_PUBLIEE || min > 10 * 60) return 0;
|
||||||
const hc = (id) => (st.get(id)?.state || '').slice(0, 5);
|
const hc = (id) => (st.get(id)?.state || '').slice(0, 5);
|
||||||
@@ -1166,9 +1197,36 @@ async function revueDeLaJournee(sql, client, st) {
|
|||||||
constats: r.constats.map((c) => ({ code: c.code, gravite: c.gravite, texte: c.texte, contexte: c.contexte })),
|
constats: r.constats.map((c) => ({ code: c.code, gravite: c.gravite, texte: c.texte, contexte: c.contexte })),
|
||||||
texte: r.texte,
|
texte: r.texte,
|
||||||
});
|
});
|
||||||
|
for (const c of r.constats) {
|
||||||
|
await sql(`INSERT INTO revue_constat (jour, code, gravite, debut, fin, texte, contexte, chiffres)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT DO NOTHING`,
|
||||||
|
[jour, c.code, c.gravite, new Date(c.debut), new Date(c.fin), c.texte, c.contexte, JSON.stringify(c.chiffres || {})]);
|
||||||
|
}
|
||||||
await journaliser(sql, 'revue', `Revue de la journee : ${aExaminer} point(s) a examiner`, { jour, texte: r.texte });
|
await journaliser(sql, 'revue', `Revue de la journee : ${aExaminer} point(s) a examiner`, { jour, texte: r.texte });
|
||||||
|
await publierRevueHistorique(client);
|
||||||
n++;
|
n++;
|
||||||
}
|
}
|
||||||
|
// Les pistes du conseiller (retenues ET ecartees) sont historisees des qu'il a
|
||||||
|
// repondu, independamment du courriel.
|
||||||
|
{
|
||||||
|
const avis = st.get('sensor.evcs_advisor_revue');
|
||||||
|
if (avis && avis.state === jour && !(await dejaFait(client, 'revue_pistes', jour))) {
|
||||||
|
const at = avis.attributes || {};
|
||||||
|
const lignes = [
|
||||||
|
...(at.pistes || []).map((p) => ({ ...p, retenue: true, motif: null })),
|
||||||
|
...(at.ecartees || []).map((p) => ({ ...p, retenue: false })),
|
||||||
|
];
|
||||||
|
for (const p of lignes) {
|
||||||
|
await sql(`INSERT INTO revue_piste (jour, code, piste, retenue, motif, modele, source)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT DO NOTHING`,
|
||||||
|
[jour, p.code || '', p.piste || '', p.retenue, p.motif, at.modele || null, at.source || null]);
|
||||||
|
}
|
||||||
|
await journaliser(sql, 'revue_pistes', `Revue : ${(at.pistes || []).length} piste(s) du conseiller, ${(at.ecartees || []).length} ecartee(s)`,
|
||||||
|
{ jour, source: at.source || null, modele: at.modele || null });
|
||||||
|
await publierRevueHistorique(client);
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (min >= REVUE_COURRIEL && !(await dejaFait(client, 'revue_courriel', jour))) {
|
if (min >= REVUE_COURRIEL && !(await dejaFait(client, 'revue_courriel', jour))) {
|
||||||
const revue = st.get('sensor.evcs_revue_jour');
|
const revue = st.get('sensor.evcs_revue_jour');
|
||||||
const avis = st.get('sensor.evcs_advisor_revue');
|
const avis = st.get('sensor.evcs_advisor_revue');
|
||||||
@@ -1176,6 +1234,7 @@ async function revueDeLaJournee(sql, client, st) {
|
|||||||
if (revue && revue.state === jour && (avisDuJour || min >= REVUE_COURRIEL_SANS_AVIS)) {
|
if (revue && revue.state === jour && (avisDuJour || min >= REVUE_COURRIEL_SANS_AVIS)) {
|
||||||
const a = revue.attributes || {};
|
const a = revue.attributes || {};
|
||||||
const pistes = (avisDuJour?.pistes || []).map((p) => `- ${p.piste}`);
|
const pistes = (avisDuJour?.pistes || []).map((p) => `- ${p.piste}`);
|
||||||
|
const quiRepond = avisDuJour ? (avisDuJour.source_texte || '') : '';
|
||||||
const texte = [
|
const texte = [
|
||||||
`Revue de la journee (24 h jusqu'a la fin des heures creuses de ce matin)`,
|
`Revue de la journee (24 h jusqu'a la fin des heures creuses de ce matin)`,
|
||||||
'',
|
'',
|
||||||
@@ -1183,17 +1242,41 @@ async function revueDeLaJournee(sql, client, st) {
|
|||||||
a.texte || '(aucun)',
|
a.texte || '(aucun)',
|
||||||
'',
|
'',
|
||||||
a.a_examiner > 0
|
a.a_examiner > 0
|
||||||
? (pistes.length ? ['PISTES DU CONSEILLER IA (avis a verifier, pas des faits) :', ...pistes].join('\n')
|
? (pistes.length ? ['PISTES DU CONSEILLER IA (avis a verifier, pas des faits) :', ...pistes,
|
||||||
|
`(${quiRepond}${avisDuJour.rejetees ? ` ${avisDuJour.rejetees} piste(s) ecartee(s).` : ''})`].join('\n')
|
||||||
: `Pistes du conseiller IA : ${avisDuJour ? 'aucune' : 'pas de reponse ce matin'}.`)
|
: `Pistes du conseiller IA : ${avisDuJour ? 'aucune' : 'pas de reponse ce matin'}.`)
|
||||||
: '',
|
: '',
|
||||||
].join('\n').trim();
|
].join('\n').trim();
|
||||||
await envoyer(texte, `EVCS - revue de la journee${a.a_examiner > 0 ? ` (${a.a_examiner} point(s))` : ''}`);
|
await envoyer(texte, `EVCS - revue de la journee${a.a_examiner > 0 ? ` (${a.a_examiner} point(s))` : ''}`);
|
||||||
await journaliser(sql, 'revue_courriel', 'Revue de la journee envoyee par courriel', { jour, avis: !!avisDuJour });
|
await journaliser(sql, 'revue_courriel', 'Revue de la journee envoyee par courriel', { jour, avis: !!avisDuJour });
|
||||||
|
await publierRevueHistorique(client);
|
||||||
n++;
|
n++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
// Historique des revues (7 derniers jours) pour la carte du conseiller :
|
||||||
|
// combien de points par jour, et ce qu'il en a dit. Publie a chaque nouvelle
|
||||||
|
// revue ou reponse, et au demarrage.
|
||||||
|
let revueHistoriquePubliee = false;
|
||||||
|
async function publierRevueHistorique(client) {
|
||||||
|
const { rows: c } = await client.query(`SELECT to_char(jour,'YYYY-MM-DD') jour, code, gravite, texte
|
||||||
|
FROM revue_constat WHERE jour > current_date - 7 ORDER BY jour DESC, debut`);
|
||||||
|
const { rows: p } = await client.query(`SELECT to_char(jour,'YYYY-MM-DD') jour, code, piste, retenue, motif, source
|
||||||
|
FROM revue_piste WHERE jour > current_date - 7 ORDER BY jour DESC, retenue DESC`);
|
||||||
|
const jours = [...new Set(c.map((x) => x.jour))];
|
||||||
|
await publierCapteur('sensor.evcs_revue_historique', jours.length, {
|
||||||
|
friendly_name: 'Revues des 7 derniers jours',
|
||||||
|
jours: jours.map((j) => ({
|
||||||
|
jour: j,
|
||||||
|
constats: c.filter((x) => x.jour === j).map(({ code, gravite, texte }) => ({ code, gravite, texte })),
|
||||||
|
pistes: p.filter((x) => x.jour === j).map(({ code, piste, retenue, motif, source }) => ({ code, piste, retenue, motif, source })),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
revueHistoriquePubliee = true;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
function decalageParis(jour) {
|
function decalageParis(jour) {
|
||||||
const d = new Date(`${jour}T12:00:00Z`);
|
const d = new Date(`${jour}T12:00:00Z`);
|
||||||
const h = Number(new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/Paris', hour: '2-digit', hour12: false }).format(d));
|
const h = Number(new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/Paris', hour: '2-digit', hour12: false }).format(d));
|
||||||
|
|||||||
@@ -235,7 +235,7 @@
|
|||||||
-30
|
-30
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"jsCode": "// =====================================================\n// EVCS ADVISOR — VALIDATE_PARSE (relecteur, 08/10/2026)\n// Valide STRICTEMENT la sortie LLM (locale OU cloud, format OpenAI).\n// - JSON {\"pistes\":[{code, piste}]} ; sinon REJET TOTAL.\n// - une piste dont le code n'est pas celui d'un constat est ecartee ;\n// - ANTI-INVENTION : une piste qui contient un nombre absent du constat et de\n// son contexte est ecartee (le modele ne produit aucun chiffre, §79).\n// =====================================================\n\nconst resp = $input.first().json;\nconst context = $('BUILD_PROMPT').first().json.context;\n\nfunction fail(status, detail) {\n return [{ json: { status, detail: (detail || '').toString().slice(0, 200), pistes: null, rejetees: 0,\n modelUsed: (resp?.model || 'aucun').toString().slice(0, 60), context } }];\n}\n\nconst content = resp?.choices?.[0]?.message?.content;\nif (typeof content !== 'string' || !content.trim()) {\n return fail('llm_offline', resp?.error?.message || resp?.error || 'aucune réponse LLM');\n}\nconst text = content.replace(/<think>[\\s\\S]*?<\\/think>/gi, '').replace(/```(?:json)?/gi, '').trim();\nconst start = text.indexOf('{');\nif (start === -1) return fail('invalid', 'aucun JSON dans la réponse');\nlet depth = 0, end = -1;\nfor (let i = start; i < text.length; i++) {\n if (text[i] === '{') depth++;\n else if (text[i] === '}') { depth--; if (depth === 0) { end = i; break; } }\n}\nif (end === -1) return fail('invalid', 'JSON non terminé');\nlet obj;\ntry { obj = JSON.parse(text.slice(start, end + 1)); } catch (e) { return fail('invalid', `JSON invalide: ${e.message}`); }\nif (!Array.isArray(obj.pistes)) return fail('invalid', 'champ pistes absent');\n\n// Nombres d'un texte, normalises (virgule decimale -> point, sans zeros inutiles).\nconst nombres = (s) => (String(s).match(/\\d+(?:[.,]\\d+)?/g) || []).map((x) => String(Number(x.replace(',', '.'))));\n\nconst parCode = new Map(context.constats.map((c) => [c.code, c]));\nconst pistes = [];\nlet rejetees = 0;\nfor (const p of obj.pistes) {\n const code = (p?.code ?? '').toString().trim();\n const piste = (p?.piste ?? '').toString().trim();\n const c = parCode.get(code);\n if (!c || !piste) { rejetees++; continue; }\n const permis = new Set(nombres(`${c.texte} ${c.contexte}`));\n if (nombres(piste).some((n) => !permis.has(n))) { rejetees++; continue; }\n if (pistes.some((x) => x.code === code)) { rejetees++; continue; }\n pistes.push({ code, piste: piste.slice(0, 280) });\n}\n\nreturn [{ json: { status: 'ok', detail: '', pistes, rejetees,\n modelUsed: (resp.model || 'inconnu').toString().slice(0, 60), context } }];\n"
|
"jsCode": "// =====================================================\n// EVCS ADVISOR — VALIDATE_PARSE (relecteur, 08/10/2026)\n// Valide STRICTEMENT la sortie LLM (locale OU cloud, format OpenAI).\n// - JSON {\"pistes\":[{code, piste}]} ; sinon REJET TOTAL.\n// - une piste dont le code n'est pas celui d'un constat est ecartee ;\n// - ANTI-INVENTION : une piste qui contient un nombre absent du constat et de\n// son contexte est ecartee (le modele ne produit aucun chiffre, §79).\n// =====================================================\n\nconst resp = $input.first().json;\nconst context = $('BUILD_PROMPT').first().json.context;\n\n// QUI A REPONDU (08/10, proprietaire : « OpenRouter, garde-le mais affiche-le\n// clairement »). Le repli cloud n'est appele que si le modele local de la Spark\n// echoue ; dans ce cas les constats ont quitte la maison et il faut le dire.\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';\n\nfunction fail(status, detail) {\n return [{ json: { status, detail: (detail || '').toString().slice(0, 200), pistes: null, rejetees: 0, ecartees: [],\n source, modelUsed: (resp?.model || 'aucun').toString().slice(0, 60), context } }];\n}\n\nconst content = resp?.choices?.[0]?.message?.content;\nif (typeof content !== 'string' || !content.trim()) {\n return fail('llm_offline', resp?.error?.message || resp?.error || 'aucune réponse LLM');\n}\nconst text = content.replace(/<think>[\\s\\S]*?<\\/think>/gi, '').replace(/```(?:json)?/gi, '').trim();\nconst start = text.indexOf('{');\nif (start === -1) return fail('invalid', 'aucun JSON dans la réponse');\nlet depth = 0, end = -1;\nfor (let i = start; i < text.length; i++) {\n if (text[i] === '{') depth++;\n else if (text[i] === '}') { depth--; if (depth === 0) { end = i; break; } }\n}\nif (end === -1) return fail('invalid', 'JSON non terminé');\nlet obj;\ntry { obj = JSON.parse(text.slice(start, end + 1)); } catch (e) { return fail('invalid', `JSON invalide: ${e.message}`); }\nif (!Array.isArray(obj.pistes)) return fail('invalid', 'champ pistes absent');\n\n// Nombres d'un texte, normalises (virgule decimale -> point, sans zeros inutiles).\nconst nombres = (s) => (String(s).match(/\\d+(?:[.,]\\d+)?/g) || []).map((x) => String(Number(x.replace(',', '.'))));\n\nconst parCode = new Map(context.constats.map((c) => [c.code, c]));\n// Les pistes ECARTEES sont gardees avec leur motif : elles sont historisees\n// pour rejouer le conseiller plus tard (regression) et juger s'il progresse.\nconst pistes = [];\nconst ecartees = [];\nconst ecarter = (code, piste, motif) => ecartees.push({ code, piste: piste.slice(0, 280), motif });\nfor (const p of obj.pistes) {\n const code = (p?.code ?? '').toString().trim();\n const piste = (p?.piste ?? '').toString().trim();\n const c = parCode.get(code);\n if (!piste) { ecarter(code, '', 'piste vide'); continue; }\n if (!c) { ecarter(code, piste, 'code inconnu ou constat non a examiner'); continue; }\n const permis = new Set(nombres(`${c.texte} ${c.contexte}`));\n const inventes = nombres(piste).filter((n) => !permis.has(n));\n if (inventes.length) { ecarter(code, piste, `chiffre absent du constat : ${inventes.join(', ')}`); continue; }\n if (pistes.some((x) => x.code === code)) { ecarter(code, piste, 'doublon'); continue; }\n pistes.push({ code, piste: piste.slice(0, 280) });\n}\n\nreturn [{ json: { status: 'ok', detail: '', pistes, rejetees: ecartees.length, ecartees, source,\n modelUsed: (resp.model || 'inconnu').toString().slice(0, 60), context } }];\n"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -247,7 +247,7 @@
|
|||||||
-30
|
-30
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"jsCode": "// =====================================================\n// EVCS ADVISOR — BUILD_HA_CALLS (relecteur, 08/10/2026)\n// GARANTIE : ce node n'ecrit QUE des entites advisor (input_text.evcs_advisor_*,\n// sensor.evcs_advisor_revue) et le logbook. AUCUN switch, AUCUNE strategie,\n// AUCUNE commande. Ne pas ajouter d'autres entites ici.\n// Les items `state_entity` sont envoyes a /api/states (capteur avec attributs),\n// les autres a /api/services/<domain>/<service>.\n// =====================================================\n\nconst { status, detail, pistes, rejetees, modelUsed, context } = $input.first().json;\n\nfunction stampParis() {\n const p = new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit',\n hour: '2-digit', minute: '2-digit', hour12: false }).formatToParts(new Date());\n const g = (t) => p.find((x) => x.type === t)?.value || '';\n return `${g('day')}/${g('month')} ${g('hour')}:${g('minute')}`;\n}\n\nconst calls = [];\nconst push = (domain, service, data) => calls.push({ json: { domain, service, data } });\nconst setText = (entity, value) => push('input_text', 'set_value', { entity_id: entity, value: (value ?? '').toString().slice(0, 250) });\n\nsetText('input_text.evcs_advisor_status', status + (detail ? `: ${detail}` : ''));\nsetText('input_text.evcs_advisor_last_run', stampParis());\nsetText('input_text.evcs_advisor_model', modelUsed);\n\nif (status === 'ok' && Array.isArray(pistes)) {\n calls.push({ json: { state_entity: 'sensor.evcs_advisor_revue', data: {\n state: context.jour,\n attributes: { friendly_name: 'Revue du conseiller IA', pistes, rejetees, modele: modelUsed,\n avertissement: 'Avis a verifier, pas des faits. Les constats chiffres sont dans sensor.evcs_revue_jour.' },\n } } });\n setText('input_text.evcs_advisor_reason',\n `Revue du ${context.jour} : ${pistes.length} piste(s)${rejetees ? `, ${rejetees} ecartee(s)` : ''}`);\n push('logbook', 'log', {\n name: 'EVCS Advisor (relecteur)',\n message: pistes.map((p) => `[${p.code}] ${p.piste}`).join(' || ') || 'aucune piste retenue',\n entity_id: 'input_text.evcs_advisor_reason',\n });\n}\n\nreturn calls;\n"
|
"jsCode": "// =====================================================\n// EVCS ADVISOR — BUILD_HA_CALLS (relecteur, 08/10/2026)\n// GARANTIE : ce node n'ecrit QUE des entites advisor (input_text.evcs_advisor_*,\n// sensor.evcs_advisor_revue) et le logbook. AUCUN switch, AUCUNE strategie,\n// AUCUNE commande. Ne pas ajouter d'autres entites ici.\n// Les items `state_entity` sont envoyes a /api/states (capteur avec attributs),\n// les autres a /api/services/<domain>/<service>.\n// =====================================================\n\nconst { status, detail, pistes, rejetees, ecartees, source, modelUsed, context } = $input.first().json;\n\nfunction stampParis() {\n const p = new Intl.DateTimeFormat('fr-FR', { timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit',\n hour: '2-digit', minute: '2-digit', hour12: false }).formatToParts(new Date());\n const g = (t) => p.find((x) => x.type === t)?.value || '';\n return `${g('day')}/${g('month')} ${g('hour')}:${g('minute')}`;\n}\n\nconst calls = [];\nconst push = (domain, service, data) => calls.push({ json: { domain, service, data } });\nconst setText = (entity, value) => push('input_text', 'set_value', { entity_id: entity, value: (value ?? '').toString().slice(0, 250) });\n\nsetText('input_text.evcs_advisor_status', status + (detail ? `: ${detail}` : ''));\nsetText('input_text.evcs_advisor_last_run', stampParis());\nsetText('input_text.evcs_advisor_model', `${modelUsed} (${source === 'openrouter' ? 'OpenRouter, cloud' : 'local Spark'})`);\n\nif (status === 'ok' && Array.isArray(pistes)) {\n calls.push({ json: { state_entity: 'sensor.evcs_advisor_revue', data: {\n state: context.jour,\n attributes: { friendly_name: 'Revue du conseiller IA', pistes, rejetees, ecartees: ecartees || [], modele: modelUsed,\n source,\n source_texte: source === 'openrouter'\n ? 'Repli OpenRouter (cloud) : le modele local de la Spark n a pas repondu, les constats ont ete envoyes a OpenRouter.'\n : 'Modele local de la Spark : rien n est sorti de la maison.',\n constats_relus: context.constats.length,\n avertissement: 'Avis a verifier, pas des faits. Les constats chiffres sont dans sensor.evcs_revue_jour.' },\n } } });\n setText('input_text.evcs_advisor_reason',\n `Revue du ${context.jour} : ${pistes.length} piste(s)${rejetees ? `, ${rejetees} ecartee(s)` : ''}`);\n push('logbook', 'log', {\n name: 'EVCS Advisor (relecteur)',\n message: pistes.map((p) => `[${p.code}] ${p.piste}`).join(' || ') || 'aucune piste retenue',\n entity_id: 'input_text.evcs_advisor_reason',\n });\n}\n\nreturn calls;\n"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user