#!/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": , "valeur": , "sens": <"monte"|"descend"|null>, "texte": ""} 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(/[\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;ip.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 {}