From b25770736fd36ea4c5242a2de09aec98314a0d3f Mon Sep 17 00:00:00 2001 From: Tallagrand Thierry Date: Thu, 8 Oct 2026 05:43:54 +0200 Subject: [PATCH] Revue : pistes ecartees et source (local/OpenRouter) gardees, historisation pour rejeu, carte verbeuse du conseiller --- HANDOFF.md | 14 +++- dashboards/evcs_master.yaml | 102 +++++++++++++++++++------- src/advisor/BUILD_HA_CALLS.js | 11 ++- src/advisor/VALIDATE_PARSE.js | 26 +++++-- tests/advisor/run.mjs | 5 ++ tools/historise-evcs.mjs | 85 ++++++++++++++++++++- workflows/evcs-advisor-shadow-v1.json | 4 +- 7 files changed, 206 insertions(+), 41 deletions(-) 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" } }, {