diff --git a/HANDOFF.md b/HANDOFF.md
index 891ef57..aeae0d9 100644
--- a/HANDOFF.md
+++ b/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 /
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*
diff --git a/dashboards/evcs_master.yaml b/dashboards/evcs_master.yaml
index e572c75..0f00a91 100755
--- a/dashboards/evcs_master.yaml
+++ b/dashboards/evcs_master.yaml
@@ -198,36 +198,84 @@ views:
- type: vertical-stack
cards:
- type: custom:mushroom-title-card
- title: 🤖 Conseiller IA (observateur)
- subtitle: Il regarde et signale — il ne décide rien
+ title: 🤖 Conseiller IA — relecteur de la journée
+ 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 %}
Raison publiée par le contrôleur à ce moment-là : {{ c.contexte }}{% 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 %}
+
💡 **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 %}
+
🚫 Piste écartée ({{ e.motif }}) : {{ e.piste }}
+ {% 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 %}
+
• {{ c.texte }}
+ {% endfor %}
+
+ {% endfor %}
+ {% endif %}
+ 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.
+
+ - 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
- title: Dernière observation
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
name: Conseiller actif (kill-switch)
diff --git a/src/advisor/BUILD_HA_CALLS.js b/src/advisor/BUILD_HA_CALLS.js
index b28b57f..674de57 100644
--- a/src/advisor/BUILD_HA_CALLS.js
+++ b/src/advisor/BUILD_HA_CALLS.js
@@ -7,7 +7,7 @@
// les autres a /api/services//.
// =====================================================
-const { status, detail, pistes, rejetees, modelUsed, context } = $input.first().json;
+const { status, detail, pistes, rejetees, ecartees, source, modelUsed, context } = $input.first().json;
function stampParis() {
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_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)) {
calls.push({ json: { state_entity: 'sensor.evcs_advisor_revue', data: {
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.' },
} } });
setText('input_text.evcs_advisor_reason',
diff --git a/src/advisor/VALIDATE_PARSE.js b/src/advisor/VALIDATE_PARSE.js
index a7bdde3..2bc80d9 100644
--- a/src/advisor/VALIDATE_PARSE.js
+++ b/src/advisor/VALIDATE_PARSE.js
@@ -10,9 +10,16 @@
const resp = $input.first().json;
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) {
- return [{ json: { status, detail: (detail || '').toString().slice(0, 200), pistes: null, rejetees: 0,
- modelUsed: (resp?.model || 'aucun').toString().slice(0, 60), context } }];
+ return [{ json: { status, detail: (detail || '').toString().slice(0, 200), pistes: null, rejetees: 0, ecartees: [],
+ source, modelUsed: (resp?.model || 'aucun').toString().slice(0, 60), context } }];
}
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 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 = [];
-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) {
const code = (p?.code ?? '').toString().trim();
const piste = (p?.piste ?? '').toString().trim();
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}`));
- if (nombres(piste).some((n) => !permis.has(n))) { rejetees++; continue; }
- if (pistes.some((x) => x.code === code)) { rejetees++; continue; }
+ const inventes = nombres(piste).filter((n) => !permis.has(n));
+ 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) });
}
-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 } }];
diff --git a/tests/advisor/run.mjs b/tests/advisor/run.mjs
index 8d24c71..ede69df 100644
--- a/tests/advisor/run.mjs
+++ b/tests/advisor/run.mjs
@@ -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: texte sans JSON -> invalid', v('Je pense que tout va bien.').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');
// ---------- 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))));
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: 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);
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')));
diff --git a/tools/historise-evcs.mjs b/tools/historise-evcs.mjs
index 9052a3e..576220f 100644
--- a/tools/historise-evcs.mjs
+++ b/tools/historise-evcs.mjs
@@ -779,6 +779,36 @@ async function migrer(client) {
// 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');
+ // 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
// 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
@@ -1147,6 +1177,7 @@ async function dejaFait(client, genre, jour) {
}
async function revueDeLaJournee(sql, client, st) {
if (!client) return 0;
+ if (!revueHistoriquePubliee) await publierRevueHistorique(client);
const { min, jour } = partsParis();
if (min < REVUE_PUBLIEE || min > 10 * 60) return 0;
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 })),
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 publierRevueHistorique(client);
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))) {
const revue = st.get('sensor.evcs_revue_jour');
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)) {
const a = revue.attributes || {};
const pistes = (avisDuJour?.pistes || []).map((p) => `- ${p.piste}`);
+ const quiRepond = avisDuJour ? (avisDuJour.source_texte || '') : '';
const texte = [
`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.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'}.`)
: '',
].join('\n').trim();
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 publierRevueHistorique(client);
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) {
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));
diff --git a/workflows/evcs-advisor-shadow-v1.json b/workflows/evcs-advisor-shadow-v1.json
index b56948c..dd9f1d1 100644
--- a/workflows/evcs-advisor-shadow-v1.json
+++ b/workflows/evcs-advisor-shadow-v1.json
@@ -235,7 +235,7 @@
-30
],
"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(/[\\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(/[\\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
],
"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//.\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//.\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"
}
},
{