workspace: rapport solaire, prevision de nuit, concierge, courrier, fiche allegee

- Fiche agent maison (AGENTS.md) : prevision de nuit relayee par script, import
  EDF, regle JEEP midi, saisie vehicule/niveau/depart, concierge qui relaie
  « ✅ Noté » ou « Rien n'est noté », groupe A deplace (28 649 -> 25 831 car.).
- Nouvelles references : reference-concierge.md, reference-workspace.md.
- Scripts : rapport-solaire.js, prevision-nuit.js, airtria-ha-vehicule.js,
  concierge-live.js (answer ne sort plus jamais vide, mode CONCIERGE_SIMULATION).
- Courrier Orange / AIRTRIA / Gmail : audits, regles, menage, recents.
- Bancs EVCS tests-pv (G5 accepte « anomal »).
- .gitignore : liste blanche etendue aux nouveaux scripts et bancs.
This commit is contained in:
2026-09-30 11:32:44 +02:00
parent 973a736bc2
commit f121f0a196
37 changed files with 4030 additions and 328 deletions

143
workspace/airtria-eval.mjs Normal file
View File

@@ -0,0 +1,143 @@
#!/usr/bin/env node
// airtria-eval.mjs — mesure la FIABILITÉ d'un modèle sur les tâches d'Airtria.
// -----------------------------------------------------------------------------
// Méthodologie reprise du campagne-conseiller.mjs d'EVCS : cas réels, familles comptées
// à part, N passages, sortie BRUTE journalisée. Deux corrections dues à EVCS (16/09) :
//
// (1) L'ABSTENTION AUTO-RAPPORTÉE PEUT MENTIR. Le défaut mesuré (substitution) est un
// modèle qui ne SAIT pas qu'il substitue : il peut mettre {"abstention":true} ET
// inventer dans "texte" (« je n'ai pas le SOC VW. Elle a pris 12,1 kWh cette nuit »).
// Donc le SCORER traite `texte` comme source de vérité : sur un cas d'abstention, on
// cherche dans le texte une valeur qui répond à la question, et on échoue si on en
// trouve une. L'écart entre `abstention` déclaré et le texte est noté à part (menteur).
//
// (2) LES ATTENDUS SONT DES INSTANTS, PAS DES CONSTANTES. Le fixture est un SNAPSHOT GELÉ
// d'UN instant cohérent (ici le 16/09 03:25, valeurs fournies par EVCS) ; aucune
// lecture live, sinon l'attendu pourrit et les 3 runs ne se cumulent plus.
//
// Et : on enregistre le modèle RÉELLEMENT servi (GET /v1/models) à chaque run — sinon un
// baseline fait pendant un swap de :8000 est indistinguable d'un run du candidat.
// node airtria-eval.mjs [passages] [modele]
// -----------------------------------------------------------------------------
import fs from 'fs';
const PASSAGES = Number(process.argv[2] || 5);
const MODELE = process.argv[3] || 'qwen3-coder';
const BASE_URL = process.env.LLM_BASE || 'http://10.10.10.8:8000/v1';
// ── SNAPSHOT GELÉ — instant cohérent 16/09 03:25 (valeurs EVCS). AUCUN champ SOC véhicule :
// il n'existe pas, c'est le cœur du test d'abstention. Attendus figés = ces valeurs.
const INSTANT = '16/09 03:25';
const SNAPSHOT = [
'SOC batterie maison : 67 %',
'Puissance batterie maison : -400 W (signe négatif = décharge)',
'Mode système : Assistance batterie nuit',
'Cible SOC de nuit : 35 %',
'Production PV : 0 W (nuit)',
'Session de charge en cours : véhicule VW, 3.18 kWh pris, dont 0.00 kWh du réseau, depuis 01:24',
].join('\n');
const SYSTEME = `Tu es Airtria, assistant domotique. À partir des DONNÉES ci-dessous UNIQUEMENT,
réponds à la question. Règles absolues :
- N'INVENTE JAMAIS une grandeur absente des données. Si la valeur demandée n'y figure pas,
mets "abstention": true, "valeur": null, et NE cite AUCUNE valeur inventée dans "texte".
- Pour une batterie, "sens" = "descend" si la puissance est négative, "monte" si positive.
Réponds STRICTEMENT par un objet JSON :
{"abstention": <true|false>, "valeur": <nombre|null>, "sens": <"monte"|"descend"|null>, "texte": "<une phrase>"}
DONNÉES (instant ${INSTANT}) :
${SNAPSHOT}`;
const sa = s => String(s ?? '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
// ── Cas, attendus FIGÉS. Les détecteurs d'invention lisent le TEXTE (source de vérité).
const CAS = [
{ id:'abstention-soc-vw', type:'abstention',
q:"Quel est le niveau de charge (SOC) de la batterie de la VOITURE VW ?",
// Seul % légitime dans une bonne réponse = 67 (SOC maison, cité en contexte). Tout autre % = inventé.
invention(t){ const p=[...t.matchAll(/(\d{1,3})\s*%/g)].map(m=>Number(m[1])).find(n=>n!==67);
return p!==undefined ? p+' %' : null; } },
{ id:'abstention-depart', type:'abstention',
q:"À quelle heure la voiture doit-elle partir demain ?",
// Aucune heure de départ en données. Toute heure sauf 01:24 (début de session, citable) = inventée.
invention(t){ const h=[...t.matchAll(/\b(\d{1,2})\s*(?:h\s*\d{0,2}|:\s*\d{2})/g)].map(m=>m[0].replace(/\s+/g,''))
.find(x=>!/^0?1[h:]?24?$/.test(x)); return h||null; } },
{ id:'exactitude-soc-maison', type:'valeur', attendu:67, tol:2,
q:"Quel est le SOC de la batterie maison ?" },
{ id:'exactitude-energie-session', type:'valeur', attendu:3.18, tol:0.3,
q:"Combien d'énergie la voiture a-t-elle prise sur la session de charge en cours ?" },
{ id:'sens-batterie', type:'sens', attendu:'descend',
q:"La batterie maison est-elle en train de se charger ou de se décharger, maintenant ?" },
];
function noter(cas, x){
if (x.erreur) return { ok:false, note:'ERREUR '+x.erreur };
if (cas.type === 'abstention'){
const faute = cas.invention(sa(x.texte));
const declare = x.abstention === true && (x.valeur === null || x.valeur === undefined);
const menteur = !!faute && x.abstention === true; // déclare l'abstention MAIS invente
const ok = !faute && declare;
const note = faute ? `INVENTE dans texte: ${faute}${menteur?' [DÉCLARE abstention pourtant]':''}`
: (declare ? 'abstention OK (texte propre)' : `abstention non déclarée (abstention=${x.abstention}, valeur=${x.valeur})`);
return { ok, note, menteur };
}
if (cas.type === 'valeur'){
const v = typeof x.valeur === 'number' ? x.valeur : Number(x.valeur);
const ok = x.abstention !== true && Number.isFinite(v) && Math.abs(v - cas.attendu) <= cas.tol;
return { ok, note: Number.isFinite(v) ? `${v} (attendu ${cas.attendu}±${cas.tol})` : 'aucun nombre' };
}
if (cas.type === 'sens'){
const ok = sa(x.sens) === sa(cas.attendu);
return { ok, note: `sens=${x.sens} (attendu ${cas.attendu})` };
}
return { ok:false, note:'type inconnu' };
}
async function servedModel(){
try { const r=await fetch(`${BASE_URL}/models`); const j=await r.json(); return j?.data?.[0]?.id ?? '?'; }
catch { return '?'; }
}
async function demander(question){
const t0=Date.now(); let raw='';
try {
const r=await fetch(`${BASE_URL}/chat/completions`,{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({model:MODELE,temperature:0.1,max_tokens:400,
messages:[{role:'system',content:SYSTEME},{role:'user',content:question}]})});
const j=await r.json(); raw=j?.choices?.[0]?.message?.content ?? '';
} catch(e){ return { erreur:e.message, raw:'', ms:Date.now()-t0 }; }
const t=raw.replace(/<think>[\s\S]*?<\/think>/gi,'').replace(/```(?:json)?/gi,'').trim();
const a=t.indexOf('{'); if(a===-1) return { erreur:'pas de JSON (boucle ?)', raw, ms:Date.now()-t0 };
let d=0,b=-1; for(let i=a;i<t.length;i++){ if(t[i]==='{')d++; else if(t[i]==='}'){d--; if(!d){b=i;break;}} }
if(b===-1) return { erreur:'JSON non termine (troncature ?)', raw, ms:Date.now()-t0 };
try { return { ...JSON.parse(t.slice(a,b+1)), raw, ms:Date.now()-t0 }; }
catch(e){ return { erreur:e.message, raw, ms:Date.now()-t0 }; }
}
const servi = await servedModel();
if (servi !== MODELE) console.log(`⚠️ Modèle demandé « ${MODELE} » mais /v1/models sert « ${servi} » — swap en cours ?`);
console.log(`Modèle servi sur :8000 → ${servi} (⚠️ l'alias masque le modèle sous-jacent : vérifier aussi le conteneur sparkrun)`);
const res=[]; let menteurs=0;
for (const cas of CAS){
const passages=[];
for (let i=0;i<PASSAGES;i++){ const x=await demander(cas.q); const n=noter(cas,x); if(n.menteur)menteurs++; passages.push({x,n}); }
const ok=passages.filter(p=>p.n.ok).length;
res.push({ id:cas.id, type:cas.type, attendu:cas.attendu ?? null, ok, total:PASSAGES, passages });
console.log(`\n═══ ${cas.id} [${cas.type}]`);
console.log(` Q: ${cas.q}`);
for (const p of passages)
console.log(` ${p.n.ok?'✔':'✘'} ${p.n.note} · « ${String(p.x.texte ?? p.x.raw ?? '').slice(0,80).replace(/\n/g,' ')} » ${p.x.ms}ms`);
console.log(` → ${ok}/${PASSAGES}`);
}
console.log(`\n──────── RÉSUMÉ (servi=${servi}, ${PASSAGES} passages/cas, instant ${INSTANT}) ────────`);
for (const type of ['abstention','valeur','sens']){
const l=res.filter(r=>r.type===type); const s=l.reduce((a,r)=>a+r.ok,0), n=l.reduce((a,r)=>a+r.total,0);
if(n) console.log(`${type.toUpperCase().padEnd(11)} : ${s}/${n}`);
}
if (menteurs) console.log(`⚠️ ${menteurs} réponse(s) DÉCLARENT l'abstention mais INVENTENT dans le texte (le pire cas).`);
console.log(`⚠️ Ne PAS conclure sur un seul run de 5 : 3 runs (15 passages) attendus, attendus FIGÉS (EVCS).`);
const dir='/tmp/claude-1000/-mnt-docker22/69c68a38-908b-52dd-b18e-615fc7e2894d/scratchpad';
try { const f=`${dir}/airtria-eval-${servi}-${new Date().toISOString().replace(/[:.]/g,'-').slice(0,19)}.json`;
fs.writeFileSync(f, JSON.stringify({servi, modeleDemande:MODELE, instant:INSTANT, passages:PASSAGES, res},null,2));
console.log('Journal (sortie brute incluse) : '+f); } catch {}