Revue : pistes ecartees et source (local/OpenRouter) gardees, historisation pour rejeu, carte verbeuse du conseiller

This commit is contained in:
Tallagrand Thierry
2026-10-08 05:43:54 +02:00
parent 78f434778b
commit b25770736f
7 changed files with 206 additions and 41 deletions

View File

@@ -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*

View File

@@ -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 %}<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
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)

View File

@@ -7,7 +7,7 @@
// 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() {
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',

View File

@@ -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 } }];

View File

@@ -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')));

View File

@@ -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));

View File

@@ -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(/<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
],
"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"
}
},
{