Revue : etat des leviers joint a chaque constat, transmis au conseiller et historise

This commit is contained in:
Tallagrand Thierry
2026-10-08 09:56:36 +02:00
parent dea9c88530
commit b7448b72c4
8 changed files with 83 additions and 11 deletions

View File

@@ -75,7 +75,7 @@
0
],
"parameters": {
"jsCode": "// =====================================================\n// EVCS ADVISOR — BUILD_PROMPT (RELECTEUR DE LA JOURNEE, 08/10/2026)\n//\n// Le conseiller n'observe plus l'instantane toutes les 15 min : 1 500 « rien a\n// signaler » contre 871 signalements sans suite, et aucun des rates du 07/10\n// (HC de midi perdue, EDF en heures pleines a 18:03, VW bloquee de 01:24 a\n// 05:14 avec EDF ferme) n'avait ete vu. Il RELIT desormais, une fois par jour,\n// les constats CALCULES par le service d'historisation (tools/revue-jour.mjs,\n// sensor.evcs_revue_jour) et propose pour chacun une piste a examiner.\n// Doctrine §79 : les chiffres viennent du code, le modele ne fait que relire.\n// Il ne pilote rien.\n//\n// Il tourne quand : le conseiller est actif, la revue du jour est publiee, il\n// y a au moins un point a examiner, et il n'a pas deja repondu aujourd'hui.\n// =====================================================\n\nconst raw = $input.first().json;\nconst input = typeof raw.data === 'string' ? JSON.parse(raw.data) : (raw.data || raw);\nconst onOff = (v) => (v ?? 'off').toString().trim().toLowerCase() === 'on';\n\nconst jourParis = new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' });\nlet constats = input.revue_jour_constats;\nif (typeof constats === 'string') { try { constats = JSON.parse(constats); } catch { constats = []; } }\nif (!Array.isArray(constats)) constats = [];\nconst aExaminer = constats.filter((c) => c && c.gravite === 'a_examiner' && c.code && c.texte);\n\nconst revueDuJour = (input.revue_jour_date || '').toString().trim() === jourParis;\nconst dejaRepondu = (input.advisor_revue_date || '').toString().trim() === jourParis;\n// Pas de relance au-dela de 10 h : un modele qui echoue ne doit pas etre\n// rappele toute la journee (le courriel part a 08:15 sans lui).\nconst heureParis = Number(new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/Paris', hour: '2-digit', hour12: false }).format(new Date()));\n\nconst ctx = {\n advisorEnabled: onOff(input.advisor_enabled),\n jour: jourParis,\n revueDuJour,\n dejaRepondu,\n constats: aExaminer.map((c) => ({ code: c.code, texte: String(c.texte), contexte: String(c.contexte || '') })),\n};\n\nconst systemPrompt = [\n \"Tu es le RELECTEUR du systeme energie d'une maison en Bretagne : 16 kWc de solaire,\",\n 'batterie de ~23 kWh utiles (0,30 kWh par point de SOC), Victron ESS SANS injection',\n '(le solaire est bride quand personne ne le prend), abonnement EDF 12 kVA, contacteur',\n 'EDF ouvert la plupart du temps, heures creuses 01:24-07:24 et 12:24-14:24, borne de',\n 'recharge pour trois vehicules. Des automates decident : le controleur de la borne,',\n \"le bilan energetique (SOC requis), l'ACM (courant de charge), l'hysteresis EDF, des\",\n 'automatisations Home Assistant.',\n '',\n 'On te donne des CONSTATS MESURES sur les dernieres 24 h. Ce sont des FAITS : ne les',\n 'conteste pas et ne les recalcule pas. Pour chacun, propose UNE piste : la cause la',\n 'plus probable et ce qu il faut verifier ou corriger. Tu ne pilotes rien.',\n '',\n 'REGLES ABSOLUES :',\n '- N ecris AUCUN chiffre qui ne figure pas dans le constat ou son contexte.',\n '- Si tu ne vois pas de cause, dis « cause non identifiable avec ces donnees ».',\n '- Une piste fait au plus 280 caracteres, en francais, sans jargon inutile.',\n '- Le contexte est la raison publiee par le controleur au debut de l episode.',\n '',\n 'Reponds UNIQUEMENT en JSON : {\"pistes\":[{\"code\":\"<code du constat>\",\"piste\":\"<texte>\"}]}',\n].join('\\n');\n\nconst userPrompt = [\n `Revue du ${jourParis}. ${aExaminer.length} constat(s) a examiner :`,\n '',\n ...aExaminer.map((c, i) => `${i + 1}. code=${c.code}\\n constat : ${c.texte}\\n contexte : ${String(c.contexte || '(aucun)').slice(0, 400)}`),\n '',\n 'JSON uniquement.',\n].join('\\n');\n\nreturn [{\n json: {\n enabled: ctx.advisorEnabled && revueDuJour && !dejaRepondu && aExaminer.length > 0 && heureParis < 10,\n messages: [\n { role: 'system', content: systemPrompt },\n { role: 'user', content: userPrompt },\n ],\n context: ctx,\n },\n}];\n"
"jsCode": "// =====================================================\n// EVCS ADVISOR — BUILD_PROMPT (RELECTEUR DE LA JOURNEE, 08/10/2026)\n//\n// Le conseiller n'observe plus l'instantane toutes les 15 min : 1 500 « rien a\n// signaler » contre 871 signalements sans suite, et aucun des rates du 07/10\n// (HC de midi perdue, EDF en heures pleines a 18:03, VW bloquee de 01:24 a\n// 05:14 avec EDF ferme) n'avait ete vu. Il RELIT desormais, une fois par jour,\n// les constats CALCULES par le service d'historisation (tools/revue-jour.mjs,\n// sensor.evcs_revue_jour) et propose pour chacun une piste a examiner.\n// Doctrine §79 : les chiffres viennent du code, le modele ne fait que relire.\n// Il ne pilote rien.\n//\n// Il tourne quand : le conseiller est actif, la revue du jour est publiee, il\n// y a au moins un point a examiner, et il n'a pas deja repondu aujourd'hui.\n// =====================================================\n\nconst raw = $input.first().json;\nconst input = typeof raw.data === 'string' ? JSON.parse(raw.data) : (raw.data || raw);\nconst onOff = (v) => (v ?? 'off').toString().trim().toLowerCase() === 'on';\n\nconst jourParis = new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' });\nlet constats = input.revue_jour_constats;\nif (typeof constats === 'string') { try { constats = JSON.parse(constats); } catch { constats = []; } }\nif (!Array.isArray(constats)) constats = [];\nconst aExaminer = constats.filter((c) => c && c.gravite === 'a_examiner' && c.code && c.texte);\n\nconst revueDuJour = (input.revue_jour_date || '').toString().trim() === jourParis;\nconst dejaRepondu = (input.advisor_revue_date || '').toString().trim() === jourParis;\n// Pas de relance au-dela de 10 h : un modele qui echoue ne doit pas etre\n// rappele toute la journee (le courriel part a 08:15 sans lui).\nconst heureParis = Number(new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/Paris', hour: '2-digit', hour12: false }).format(new Date()));\n\nconst ctx = {\n advisorEnabled: onOff(input.advisor_enabled),\n jour: jourParis,\n revueDuJour,\n dejaRepondu,\n constats: aExaminer.map((c) => ({ code: c.code, texte: String(c.texte), contexte: String(c.contexte || ''), etat: String(c.etat || '') })),\n};\n\nconst systemPrompt = [\n \"Tu es le RELECTEUR du systeme energie d'une maison en Bretagne : 16 kWc de solaire,\",\n 'batterie de ~23 kWh utiles (0,30 kWh par point de SOC), Victron ESS SANS injection',\n '(le solaire est bride quand personne ne le prend), abonnement EDF 12 kVA, contacteur',\n 'EDF ouvert la plupart du temps, heures creuses 01:24-07:24 et 12:24-14:24, borne de',\n 'recharge pour trois vehicules. Des automates decident : le controleur de la borne,',\n \"le bilan energetique (SOC requis), l'ACM (courant de charge), l'hysteresis EDF, des\",\n 'automatisations Home Assistant.',\n '',\n 'On te donne des CONSTATS MESURES sur les dernieres 24 h. Ce sont des FAITS : ne les',\n 'conteste pas et ne les recalcule pas. Pour chacun, propose UNE piste : la cause la',\n 'plus probable et ce qu il faut verifier ou corriger. Tu ne pilotes rien.',\n '',\n 'REGLES ABSOLUES :',\n '- N ecris AUCUN chiffre qui ne figure pas dans le constat ou son contexte.',\n '- Si tu ne vois pas de cause, dis « cause non identifiable avec ces donnees ».',\n '- Une piste fait au plus 280 caracteres, en francais, sans jargon inutile.',\n '- Le contexte est la raison publiee par le controleur au debut de l episode.',\n '- L etat donne les leviers a ce moment-la : consigne reseau, courant de charge max fixe',\n ' par l ACM, mode de nuit (sans_edf : la voiture ne fait pas fermer EDF ; complete : elle',\n ' charge sur EDF en HC), budget voiture sans EDF, SOC requis, cible du matin. Cherche la',\n ' cause d abord dans ces leviers (un courant ACM bas, une consigne egale a la maison, un',\n ' mode sans_edf avec budget 0 expliquent souvent le constat).',\n '',\n 'Reponds UNIQUEMENT en JSON : {\"pistes\":[{\"code\":\"<code du constat>\",\"piste\":\"<texte>\"}]}',\n].join('\\n');\n\nconst userPrompt = [\n `Revue du ${jourParis}. ${aExaminer.length} constat(s) a examiner :`,\n '',\n ...aExaminer.map((c, i) => `${i + 1}. code=${c.code}\\n constat : ${c.texte}\\n contexte : ${String(c.contexte || '(aucun)').slice(0, 400)}\\n etat : ${String(c.etat || '(inconnu)').slice(0, 600)}`),\n '',\n 'JSON uniquement.',\n].join('\\n');\n\nreturn [{\n json: {\n enabled: ctx.advisorEnabled && revueDuJour && !dejaRepondu && aExaminer.length > 0 && heureParis < 10,\n messages: [\n { role: 'system', content: systemPrompt },\n { role: 'user', content: userPrompt },\n ],\n context: ctx,\n },\n}];\n"
}
},
{
@@ -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\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"
"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} ${c.etat || ''}`)\n .flatMap((n) => [n, String(Math.round(Number(n))), String(Math.round(Number(n) * 10) / 10)]));\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"
}
},
{