Laya-demo / web /index.html
robynsd's picture
Laya demo API
5c04e34
Raw History Blame Contribute Delete
45.7 kB
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Laya Démo</title>
<script>
// Applied before first paint to avoid a flash of the wrong theme.
try { const t = localStorage.getItem("theme"); if (t) document.documentElement.dataset.theme = t; } catch {}
</script>
<style>
:root {
--bg: #f6f5f2; --surface: #ffffff; --text: #1d1d1b; --muted: #6b6b66;
--border: #e2e0da; --accent: #2f6fed; --accent-soft: #e8efff;
--bad: #d64545; --mid: #e0a72e; --good: #2f9e62;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --bg: #161615; --surface: #1f1f1d; --text: #ecebe6;
--muted: #9a9a93; --border: #33332f; --accent: #6b9bff; --accent-soft: #1e2a44; color-scheme: dark; }
}
:root[data-theme="dark"] { --bg: #161615; --surface: #1f1f1d; --text: #ecebe6;
--muted: #9a9a93; --border: #33332f; --accent: #6b9bff; --accent-soft: #1e2a44; color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text);
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Inter", sans-serif; }
main { max-width: 980px; margin: 0 auto; padding: 32px 16px; }
[hidden] { display: none !important; }
header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; flex-wrap: wrap; }
h1 { font-size: 20px; margin: 0 0 4px; }
.sub { color: var(--muted); margin: 0 0 24px; }
.controls { display: flex; gap: 8px; flex-wrap: wrap; }
.seg { display: flex; border: 1px solid var(--border); border-radius: 999px; padding: 2px;
background: var(--surface); }
.seg button, .seg a { border: 0; background: none; color: var(--muted); font: inherit; font-size: 12px;
padding: 4px 10px; border-radius: 999px; cursor: pointer; text-decoration: none; }
.seg [aria-pressed="true"], .seg [aria-current="page"] { background: var(--border); color: var(--text); }
nav.cases { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
nav.cases button { border: 1px solid var(--border); background: var(--surface); color: var(--text);
padding: 6px 14px; border-radius: 999px; font: inherit; cursor: pointer; }
nav.cases button[aria-selected="true"] { border-color: var(--accent); color: var(--accent); }
nav.cases button:disabled { opacity: .45; cursor: default; }
.grid { display: grid; grid-template-columns: 1fr 300px; gap: 20px; }
@media (max-width: 760px) { .grid { grid-template-columns: 1fr; } }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 20px; }
textarea { width: 100%; min-height: 320px; resize: vertical; border: 0; outline: none;
background: transparent; color: var(--text); font: inherit; font-size: 16px; }
.examples { margin-top: 12px; display: flex; gap: 6px; flex-wrap: wrap; }
.examples button { font: inherit; font-size: 12px; border: 1px dashed var(--border);
background: none; color: var(--muted); padding: 3px 10px; border-radius: 6px; cursor: pointer; }
.gauge { font-size: 64px; font-weight: 700; line-height: 1; font-variant-numeric: tabular-nums;
transition: color .2s; }
.gauge small { font-size: 20px; color: var(--muted); font-weight: 500; }
.bar { height: 8px; background: var(--border); border-radius: 4px; overflow: hidden; margin: 14px 0 6px; }
.bar > div { height: 100%; width: 0; transition: width .2s, background .2s; }
.verdict { color: var(--muted); margin-bottom: 20px; }
.sig { display: grid; grid-template-columns: 80px 1fr 40px; gap: 10px; align-items: center;
font-size: 13px; margin: 8px 0; }
.sig .bar { margin: 0; height: 6px; }
.sig span:last-child { text-align: right; font-variant-numeric: tabular-nums; color: var(--muted); }
.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 20px;
padding-top: 16px; border-top: 1px solid var(--border); }
.stats div { display: flex; flex-direction: column; gap: 2px; }
.stats b { font-size: 18px; font-variant-numeric: tabular-nums; }
.stats span { color: var(--muted); font-size: 11px; }
.prose { margin-top: 20px; }
.prose h2 { font-size: 17px; margin: 0 0 6px; }
.prose h3 { font-size: 14px; margin: 28px 0 6px; }
.prose p, .prose li { color: var(--muted); font-size: 14px; }
.prose p { margin: 6px 0; }
.prose ol, .prose ul { padding-left: 20px; margin: 6px 0; }
.prose b { color: var(--text); font-weight: 600; }
.prose a { color: var(--accent); }
code { font-size: 12px; background: var(--border); padding: 1px 5px; border-radius: 4px; color: var(--text); }
.step { font-size: 11px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
color: var(--accent); margin-right: 6px; }
.quote { border-left: 3px solid var(--border); padding: 4px 0 4px 12px; margin: 8px 0;
font-size: 13px; color: var(--text); }
.scroll { overflow-x: auto; margin: 8px 0; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { text-align: left; font-weight: 600; color: var(--muted); font-size: 11px; text-transform: uppercase;
letter-spacing: .03em; padding: 6px 8px; border-bottom: 1px solid var(--border); }
td { padding: 7px 8px; border-bottom: 1px solid var(--border); vertical-align: top; }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
tr.lead td:first-child { box-shadow: inset 3px 0 0 var(--accent); }
tr.top td { background: var(--accent-soft); }
tfoot td { font-weight: 600; border-bottom: 0; }
.en { color: var(--muted); }
pre { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 12px;
font-size: 12.5px; overflow-x: auto; margin: 8px 0; }
.flow { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 12px 0; font-size: 13px; }
.flow span { border: 1px solid var(--border); border-radius: 6px; padding: 4px 10px; background: var(--bg); }
.flow i { color: var(--muted); font-style: normal; }
.live { color: var(--accent); }
details.prose > summary { cursor: pointer; list-style: none; display: flex; align-items: center; gap: 8px; }
details.prose > summary::-webkit-details-marker { display: none; }
details.prose > summary::before { content: "›"; color: var(--muted); font-size: 18px; line-height: 1;
transition: transform .15s; }
details.prose[open] > summary::before { transform: rotate(90deg); }
details.prose > summary h2 { margin: 0; }
details.prose[open] > summary { margin-bottom: 8px; }
.current { border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 8px;
padding: 12px 14px; margin: 14px 0 4px; background: var(--bg); }
.current .label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .03em;
color: var(--accent); margin-bottom: 4px; }
.current .body { font-size: 14px; color: var(--text); white-space: pre-wrap; }
.current .meta { font-size: 12px; color: var(--muted); margin-top: 6px; }
.current.empty .body { color: var(--muted); font-style: italic; }
.models { color: var(--muted); font-size: 11px; margin-top: 10px; }
.headline { font-size: 44px; font-weight: 700; line-height: 1.05; }
.headline small { font-size: 18px; color: var(--muted); font-weight: 500; }
.warning { background: var(--accent-soft); border-radius: 8px; padding: 8px 10px; font-size: 13px;
margin: 12px 0; }
.check { display: grid; grid-template-columns: 22px 1fr; gap: 6px; margin: 12px 0; font-size: 14px; }
.check .icon { font-weight: 700; }
.check .ok { color: var(--good); } .check .ko { color: var(--bad); }
.check .detail, .check .tip { color: var(--muted); font-size: 12px; }
.check .tip { margin-top: 2px; }
.tag { font-size: 10px; border: 1px solid var(--border); border-radius: 4px; padding: 0 4px;
color: var(--muted); margin-left: 4px; vertical-align: 1px; }
.house { font-size: 34px; font-weight: 700; line-height: 1.1; margin: 4px 0; }
.hesit { color: var(--muted); font-size: 13px; margin-bottom: 12px; }
.extra { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--border); font-size: 14px; }
.extra div { margin: 6px 0; }
.extra span { color: var(--muted); font-size: 12px; display: block; }
</style>
</head>
<body>
<main>
<header>
<div>
<h1>Laya-MLX, décisions typées en local</h1>
<p class="sub">Le modèle re-score à chaque frappe, sans génération de texte, sur ta machine.</p>
</div>
<div class="controls">
<div class="seg" role="navigation" aria-label="Vue">
<a href="#" data-view-link="demo">Démo</a>
<a href="#modele" data-view-link="modele">Comment ça marche ?</a>
</div>
<div class="seg" role="group" aria-label="Thème">
<button data-theme-choice="auto">Auto</button>
<button data-theme-choice="light">Clair</button>
<button data-theme-choice="dark">Sombre</button>
</div>
</div>
</header>
<!-- ============ Vue démo ============ -->
<section id="view-demo">
<nav class="cases" id="cases"></nav>
<div class="grid">
<div class="card">
<textarea id="text" autofocus></textarea>
<div class="examples" id="examples"></div>
</div>
<div class="card">
<div id="result"></div>
<div class="stats">
<div><b id="ms">-</b><span>Inférence</span></div>
<div><b id="tin">-</b><span>Tokens en entrée</span></div>
<div><b id="tout">-</b><span>Tokens générés</span></div>
</div>
<div class="models" id="models"></div>
</div>
</div>
<details class="card prose" id="case-explain">
<summary><h2>Comment cet exemple est construit</h2></summary>
<div id="case-body"><p>Chargement...</p></div>
</details>
</section>
<!-- ============ Vue modèle ============ -->
<section id="view-modele" hidden>
<div class="card prose" style="margin-top:0">
<h2>Comment fonctionne le modèle</h2>
<h3>Un modèle qui décide, sans rédiger</h3>
<p>Un LLM classique (ChatGPT, Claude) écrit sa réponse mot après mot. Pour obtenir un simple chiffre,
il faut attendre qu'il finisse sa phrase, puis espérer qu'il a respecté le format demandé.</p>
<p>Laya fonctionne comme un <b>questionnaire à choix multiples</b> : on lui donne un texte et une
question avec ses réponses possibles, et il renvoie directement une probabilité pour chaque réponse,
en une seule passe. Aucun mot n'est généré. Conséquences :</p>
<ul>
<li><b>rapide</b> : une lecture du texte au lieu d'une rédaction mot par mot ;</li>
<li><b>fiable dans la forme</b> : la réponse est forcément l'une des options prévues ;</li>
<li><b>local</b> : le modèle est assez petit pour tourner sur un Mac, aucune donnée ne quitte la machine.</li>
</ul>
<div class="flow">
<span>texte + question + options</span><i>→</i>
<span>encodeur bidirectionnel</span><i>→</i>
<span>tête de décision</span><i>→</i>
<span>probabilités calibrées</span>
</div>
<h3>Trois types de questions</h3>
<div class="scroll"><table>
<thead><tr><th>Type</th><th>Exemple</th><th>Réponse du modèle</th></tr></thead>
<tbody>
<tr><td><code>noul</code> oui / non</td><td>« Le texte mentionne-t-il un budget ? »</td><td>P(vrai), par exemple 74 %</td></tr>
<tr><td><code>choice</code> choix</td><td>« Service concerné : facturation / technique / commercial »</td><td>Un pourcentage par option</td></tr>
<tr><td><code>score</code> échelle</td><td>« Urgence : faible / moyenne / critique »</td><td>Le niveau attendu, Σ i · pᵢ, par exemple 1,6 sur 2</td></tr>
</tbody>
</table></div>
<h3>Sous le capot</h3>
<p>Chaque question est mise en forme ainsi, puis lue d'un seul bloc :</p>
<pre>[CLS] &lt;type&gt; question [SEP] [MASK] option₀ [MASK] option₁ ... [SEP] texte [SEP]</pre>
<ol>
<li><b>Encodeur</b> : ModernBERT-large, 28 couches, dimension 1 024. Il est <b>bidirectionnel</b> :
chaque mot est interprété à la lumière de tous les autres, avant comme après lui, contrairement aux
LLM génératifs qui ne regardent que vers la gauche.</li>
<li><b>Type de question</b> : un vecteur propre à <code>choice</code>, <code>score</code> ou
<code>noul</code> est ajouté, pour que le modèle sache quel genre de réponse on attend.</li>
<li><b>Tête de décision</b> : 2 couches Transformer, puis un petit réseau (le <i>scorer</i>) lu à la
position de chaque <code>[MASK]</code>. Chaque option obtient ainsi un score qui tient compte de
la question et du texte.</li>
<li><b>Calibration</b> : les scores sont divisés par une <i>température</i>, puis transformés en
probabilités (softmax). But : qu'un « 80 % » soit correct environ 8 fois sur 10.</li>
</ol>
<h3>La calibration dépend de la forme de la question</h3>
<p>La température a été ajustée à l'entraînement selon le type de question et le nombre d'options.
Plus elle est haute, plus les probabilités sont tassées vers l'incertitude :</p>
<div class="scroll"><table>
<thead><tr><th>Forme de la question</th><th class="num">Température</th><th>Effet</th></tr></thead>
<tbody>
<tr><td>Oui / non</td><td class="num">1,98</td><td>Fortement tassé : dépasse rarement 70 %</td></tr>
<tr><td>Choix à 2 options</td><td class="num">1,91</td><td>Fortement tassé</td></tr>
<tr><td>Choix à 3 à 5 options</td><td class="num">1,76</td><td>Tassé</td></tr>
<tr><td>Échelle à 3 à 5 niveaux</td><td class="num">1,25</td><td>Légèrement tassé</td></tr>
<tr><td>Choix à 6 à 10 options, échelle à 6 niveaux ou plus</td><td class="num">≈ 1,0</td><td>Neutre</td></tr>
<tr><td>Choix à 11 options ou plus</td><td class="num">0,5</td><td>Accentué</td></tr>
</tbody>
</table></div>
<p>La forme d'une question influence donc la netteté de ses réponses, indépendamment de son contenu.</p>
<h3>Une question, une passe</h3>
<p>Le texte n'est pas lu une seule fois pour toutes les questions : chaque question est encodée avec
le texte, séparément. Les questions passent en parallèle dans un même lot, mais le nombre de tokens
en entrée est à peu près <b>nombre de questions × (texte + question)</b>. Le nombre de tokens
générés, lui, est toujours 0.</p>
<h3>Les modèles utilisés ici</h3>
<p>Des checkpoints de 421 millions de paramètres (des centaines de fois moins qu'un grand LLM), sans API
cloud. Deux moteurs exécutent les mêmes poids : <b>laya-mlx</b> sur Mac (MLX, le framework d'Apple
pour le GPU des puces M) pour les tests en local, et le <b>Laya d'origine</b> (PyTorch) sur serveur.
Quand les deux modèles sont chargés, chaque question est envoyée à celui qui y répond le mieux :</p>
<ul>
<li><code>laya-typed-decisions</code>, spécialisé dans les décisions typées : cas 1, cas 3, et la
prochaine étape et les garde-fous du cas 2 ;</li>
<li><code>laya</code> (anglais généraliste) : les détecteurs de défauts du cas 2, qu'il sépare bien
mieux. Une instance légère peut ne charger que <code>laya-typed-decisions</code>.</li>
</ul>
<p id="instance"></p>
<ul>
<li><a href="https://github.com/NandhaKishorM/laya" target="_blank" rel="noopener">Laya</a> : le modèle d'origine, créé et entraîné par Convai Innovations</li>
<li><a href="https://github.com/mizorewww/laya-mlx" target="_blank" rel="noopener">laya-mlx</a> : le portage indépendant pour Apple Silicon utilisé ici</li>
<li>Poids sur Hugging Face : <a href="https://huggingface.co/aac6fef/laya-typed-decisions-mlx" target="_blank" rel="noopener">laya-typed-decisions-mlx</a>
et <a href="https://huggingface.co/aac6fef/laya-mlx" target="_blank" rel="noopener">laya-mlx</a></li>
</ul>
</div>
</section>
</main>
<script src="config.js"></script>
<script>
const API = window.LAYA_API || "";
const $ = (id) => document.getElementById(id);
const text = $("text");
const pct = (p) => `${Math.round(p * 100)} %`;
const p1 = (p) => `${(p * 100).toFixed(1).replace(".", ",")} %`;
const num = (x) => x.toFixed(2).replace(".", ",");
const esc = (s) => String(s).replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
const argmax = (obj) => Object.keys(obj).reduce((a, k) => obj[k] > obj[a] ? k : a);
// ---- Theme ----
function setTheme(choice) {
const root = document.documentElement;
if (choice === "auto") delete root.dataset.theme; else root.dataset.theme = choice;
try { choice === "auto" ? localStorage.removeItem("theme") : localStorage.setItem("theme", choice); } catch {}
document.querySelectorAll("[data-theme-choice]").forEach(b =>
b.setAttribute("aria-pressed", b.dataset.themeChoice === choice));
}
document.querySelectorAll("[data-theme-choice]").forEach(b =>
b.addEventListener("click", () => setTheme(b.dataset.themeChoice)));
setTheme(document.documentElement.dataset.theme || "auto");
// ---- Views ----
function showView() {
const view = location.hash === "#modele" ? "modele" : "demo";
$("view-demo").hidden = view !== "demo";
$("view-modele").hidden = view !== "modele";
document.querySelectorAll("[data-view-link]").forEach(a => {
if (a.dataset.viewLink === view) a.setAttribute("aria-current", "page"); else a.removeAttribute("aria-current");
});
window.scrollTo(0, 0);
}
window.addEventListener("hashchange", showView);
showView();
// Shared pieces of the "Comment cet exemple est construit" section.
const liveCell = (p) => `<td class="num live">${p == null ? "-" : pct(p)}</td>`;
const liveHead = `<th class="num live">En cours</th>`;
function currentBox(label, r, summary) {
const current = r ? r.text : "";
return `<div class="current ${current ? "" : "empty"}">
<div class="label">${label} en cours</div>
<div class="body">${current ? esc(current) : "Rien d'analysé pour l'instant : écris un texte ou choisis un exemple ci-dessus."}</div>
${r ? `<div class="meta">Résultat : ${esc(summary(r))}</div>` : ""}
</div>`;
}
function optionsTable(options, probs, { keyHead = "Clé", labelHead = "Affiché comme", rowClass = () => "" } = {}) {
const top = probs ? argmax(probs) : null;
return `<div class="scroll"><table>
<thead><tr><th>${keyHead}</th><th>Description envoyée au modèle</th><th>${labelHead}</th>${liveHead}</tr></thead>
<tbody>${options.map(o => `<tr class="${rowClass(o)} ${o.key === top ? "top" : ""}">
<td><code>${esc(o.key)}</code></td><td class="en">${esc(o.description)}</td><td>${esc(o.label)}</td>
${liveCell(probs ? probs[o.key] : null)}</tr>`).join("")}</tbody>
</table></div>`;
}
const intro = (n) => `<p>Laya ne connaît pas la tâche : il sait seulement répondre à des questions fermées.
Ce cas la traduit en <b>${n} questions</b>, envoyées ensemble à chaque frappe, puis combine les réponses.
Les questions et options ci-dessous sont le texte exact envoyé au modèle, en anglais car les checkpoints
ont été entraînés en anglais ; le texte analysé peut être en français. La colonne
<span class="live">En cours</span> montre ce que le modèle a répondu pour le texte saisi, et la ligne
surlignée est l'option la plus probable.</p>`;
// =====================================================================
// Case 1: prospect quality
// =====================================================================
// Upper bound (exclusive) of each verdict band, for leads.
const VERDICTS = [[35, "Prospect froid"], [55, "Peu qualifié"], [68, "À creuser"],
[80, "Bon prospect"], [Infinity, "Prospect chaud"]];
const scoreColor = (v) => v < 55 ? "var(--bad)" : v < 68 ? "var(--mid)" : "var(--good)";
const prospectVerdict = (r) => r.kind !== "Prospect" ? `Pas un prospect : ${r.kind.toLowerCase()}`
: VERDICTS.find(([max]) => r.score < max)[1];
function prospectResult(r) {
if (!r) return `<div class="gauge">-<small>/100</small></div><div class="bar"><div></div></div>
<div class="verdict">Commence à écrire.</div>`;
return `<div class="gauge" style="color:${scoreColor(r.score)}">${r.score}<small>/100</small></div>
<div class="bar"><div style="width:${r.score}%;background:${scoreColor(r.score)}"></div></div>
<div class="verdict">${esc(prospectVerdict(r))}</div>
${r.signals.map(s => {
const p = Math.round(s.p * 100);
return `<div class="sig"><span>${esc(s.label)}</span>
<div class="bar"><div style="width:${p}%;background:${scoreColor(p)}"></div></div><span>${p}%</span></div>`;
}).join("")}`;
}
function prospectCalc(spec, d) {
const lines = [];
const coherent = d.coherence >= spec.coherence.threshold;
lines.push(`1. Cohérence : ${p1(d.coherence)} ${coherent ? "≥" : "<"} ${pct(spec.coherence.threshold)} ${coherent ? "✓" : "✗"}`);
if (!coherent) return lines.concat(` → note = 0, « texte incompréhensible »`).join("\n");
const leads = spec.kind.options.filter(o => o.lead);
const pLead = leads.reduce((s, o) => s + d.kinds[o.key], 0);
const isLead = pLead >= spec.kind.threshold;
lines.push(`2. P(prospect) : ${leads.map(o => p1(d.kinds[o.key])).join(" + ")}`,
` = ${p1(pLead)} ${isLead ? "≥" : "<"} ${pct(spec.kind.threshold)} ${isLead ? "✓" : "✗"}`);
if (!isLead) {
const other = spec.kind.options.filter(o => !o.lead).reduce((a, o) => d.kinds[o.key] > d.kinds[a.key] ? o : a);
return lines.concat(` → note = 0, type le plus probable : ${other.label} (${p1(d.kinds[other.key])})`).join("\n");
}
const n = d.levels.length - 1;
const expected = d.levels.reduce((s, p, i) => s + p * i, 0);
lines.push(`3. Niveau attendu = ${d.levels.map((p, i) => `${i} × ${num(p)}`).join(" + ")}`,
` = ${num(expected)} sur ${n}`,
` Note = ${num(expected)} ÷ ${n} × 100 = ${Math.round(expected / n * 100)}`);
return lines.join("\n");
}
function prospectExplain(spec, r) {
const d = r && r.details;
const kinds = spec.kind.options;
const nonLead = kinds.filter(o => !o.lead), leads = kinds.filter(o => o.lead);
const pLead = d ? leads.reduce((s, o) => s + d.kinds[o.key], 0) : null;
const levels = spec.quality.levels, n = levels.length - 1;
const expected = d ? d.levels.reduce((s, p, i) => s + p * i, 0) : null;
const topLevel = d ? d.levels.indexOf(Math.max(...d.levels)) : null;
const kindOptions = [...nonLead, ...leads].map(o => ({ ...o, label: o.lead ? "Prospect" : o.label }));
return `${intro(7)}
${currentBox("Email", r, x => `${x.score}/100, ${prospectVerdict(x)}`)}
<h3><span class="step">Étape 1</span>Quel type d'email ? <code>choice</code>, ${kinds.length} options</h3>
<div class="quote">${esc(spec.kind.instructions)}</div>
<p>Chaque option est un <b>KIND</b> : une clé (ce que le modèle voit comme nom d'option) et une courte
description (qui l'aide à comprendre ce que la clé recouvre). Le modèle répartit 100 % de probabilité
entre toutes les options. Il y a deux familles :</p>
<ul>
<li><b>${nonLead.length} types hors-sujet</b> : ${nonLead.map(o => esc(o.label.toLowerCase())).join(", ")}.
Ce sont les emails qu'une équipe commerciale reçoit en réalité, et qu'il faut savoir écarter.</li>
<li><b>${leads.length} niveaux de prospect</b> (marqués d'un trait bleu), du plus froid au plus chaud.</li>
</ul>
${optionsTable(kindOptions, d && d.kinds, { rowClass: o => o.lead ? "lead" : "" })}
<p>P(prospect) = somme des ${leads.length} niveaux de prospect = <span class="live">${d ? pct(pLead) : "-"}</span>
(seuil : ${pct(spec.kind.threshold)}).</p>
<h3><span class="step">Étape 2</span>Le texte est-il cohérent ? <code>noul</code></h3>
<div class="quote">${esc(spec.coherence.instructions)}</div>
<p>Le modèle choisit toujours une option, même face à du charabia : « azerty qsdf » était classé
« prospect » à 80 % à l'étape 1, faute de mieux. Ajouter une option « charabia » déséquilibrait les
autres, alors cette question séparée sert de garde-fou.</p>
<p><b>Règle :</b> sous ${pct(spec.coherence.threshold)}, la note est 0 et le texte est marqué
« incompréhensible ». En cours : <span class="live">${d ? pct(d.coherence) : "-"}</span>.</p>
<h3><span class="step">Étape 3</span>Quelle est la qualité du prospect ? <code>score</code>, ${levels.length} niveaux</h3>
<div class="quote">${esc(spec.quality.instructions)}</div>
<p>C'est cette question qui donne la note. Chaque niveau décrit <b>ce que l'email doit contenir</b> :
des étiquettes nues (« bon », « excellent ») séparaient beaucoup moins bien les emails.</p>
<div class="scroll"><table>
<thead><tr><th class="num">Niveau</th><th>Description envoyée au modèle</th><th class="num">Points</th>${liveHead}</tr></thead>
<tbody>${levels.map((l, i) => `<tr class="${i === topLevel ? "top" : ""}"><td class="num">${i}</td>
<td class="en">${esc(l)}</td><td class="num">${Math.round(i / n * 100)}</td>${liveCell(d ? d.levels[i] : null)}</tr>`).join("")}</tbody>
<tfoot><tr><td colspan="3">Niveau attendu = Σ niveau × probabilité, ramené sur 100</td>
<td class="num live">${d ? Math.round(expected / n * 100) : "-"}</td></tr></tfoot>
</table></div>
<p>La note n'est pas le niveau le plus probable, mais la <b>moyenne pondérée</b> : si le modèle hésite
entre « bon » et « parfait », la note tombe entre les deux. C'est aussi pourquoi elle plafonne vers 85 :
le modèle ne met jamais toute sa probabilité sur le niveau le plus haut.</p>
<h3><span class="step">Étape 4</span>Le détail BANT : ${spec.signals.length} questions <code>noul</code></h3>
<p>Budget, Authority (décideur), Need (besoin), Timing : la grille de qualification commerciale classique.
Ces réponses sont affichées en barres pour <b>expliquer</b> la note, mais <b>n'entrent pas dans son
calcul</b>.</p>
<div class="scroll"><table>
<thead><tr><th>Barre</th><th>Affirmation envoyée au modèle</th>${liveHead}</tr></thead>
<tbody>${spec.signals.map((s, i) => `<tr><td>${esc(s.label)}</td><td class="en">${esc(s.instructions)}</td>
${liveCell(r ? r.signals[i].p : null)}</tr>`).join("")}</tbody>
</table></div>
<p>Elles sont formulées comme des <b>faits vérifiables dans le texte</b> (« l'email mentionne une
échéance ») plutôt que comme des jugements (« veut-il démarrer bientôt ? ») : la version jugement
répondait oui à 66 % pour un simple « Hello, comment ça va ? ». Comme toute question oui/non, elles
dépassent rarement 70 %.</p>
<h3>Calcul final</h3>
<pre>si cohérence &lt; ${pct(spec.coherence.threshold)} → note = 0, « texte incompréhensible »
si P(prospect) &lt; ${pct(spec.kind.threshold)} → note = 0, type hors-sujet le plus probable
sinon → note = niveau attendu (étape 3) sur 100</pre>
${d ? `<p>Appliqué au <span class="live">texte en cours</span> :</p><pre>${prospectCalc(spec, d)}</pre>` : ""}
<p>Verdict affiché pour un prospect : ${VERDICTS.map(([max, label], i) =>
`${label} ${i === 0 ? `(moins de ${max})` : max === Infinity ? `(${VERDICTS[i - 1][0]} et plus)` : `(${VERDICTS[i - 1][0]} à ${max - 1})`}`).join(", ")}.</p>`;
}
// =====================================================================
// Case 2: review a sales email before sending
// =====================================================================
const reviewColor = (r) => r.passed === r.total ? "var(--good)" : r.passed >= r.total - 1 ? "var(--mid)" : "var(--bad)";
const reviewSummary = (r) => `${r.passed}/${r.total} contrôles réussis${r.warning ? ` (${r.warning})` : ""}`;
function reviewResult(r) {
if (!r) return `<div class="headline">-<small>/4</small></div><div class="verdict">Commence à écrire ton email.</div>`;
return `<div class="headline" style="color:${reviewColor(r)}">${r.passed}<small>/${r.total} contrôles réussis</small></div>
${r.warning ? `<div class="warning">${esc(r.warning)}</div>` : ""}
${r.checks.map(c => `<div class="check">
<span class="icon ${c.ok ? "ok" : "ko"}">${c.ok ? "✓" : "✗"}</span>
<div>${esc(c.label)}${c.approx ? `<span class="tag">approximatif</span>` : ""}
${c.detail ? `<div class="detail">Détecté : ${esc(c.detail)}</div>` : ""}
${c.tip ? `<div class="tip">${esc(c.tip)}</div>` : ""}</div>
</div>`).join("")}`;
}
function reviewCalc(spec, r) {
const lines = spec.checks.map((c, i) => {
const p = r.checks[i].p, present = p >= spec.threshold;
return `${c.label.padEnd(22)}: ${p1(p).padStart(7)} ${present ? "≥" : "<"} ${pct(spec.threshold)} → ${present ? "présent" : "absent "} ${r.checks[i].ok ? "✓" : "✗"}`;
});
const step = r.checks[r.checks.length - 1];
lines.push(`${"Prochaine étape claire".padEnd(22)}: option la plus probable = « ${step.detail} » (${p1(step.p)}) ${step.ok ? "✓" : "✗"}`);
lines.push("", `Résultat : ${r.passed}/${r.total}`);
return lines.join("\n");
}
function reviewExplain(spec, r) {
const d = r && r.details;
const salesOptions = Object.entries(spec.sales.options).map(([k, v]) => ({ key: k, description: v,
label: k === "sales email" ? "Email commercial" : "Avertissement" }));
return `<p>Contrairement au cas 1, il n'y a <b>pas de note sur 100</b> : sur une trentaine de formulations
testées, aucun modèle ne savait juger la qualité d'ensemble d'un email sortant (l'email le mieux écrit
était classé « brochure générique »). En revanche, Laya repère bien certains <b>faits précis</b>. Ce cas
est donc une liste de contrôles, chacun vérifié par une question séparée.</p>
${intro(6)}
${currentBox("Email", r, reviewSummary)}
<h3><span class="step">Étape 1</span>Trois détecteurs de défauts : ${spec.checks.length} questions <code>noul</code></h3>
<p>${spec.routing && !spec.routing[spec.checks[0].key].includes("typed")
? `Ces trois questions sont envoyées au modèle anglais généraliste <code>laya</code> : sur les emails de test,
il séparait les emails concernés des autres avec des écarts de 52 à 66 points, contre moins de 35 pour
<code>laya-typed-decisions</code>.`
: `Sur les emails de test, le modèle anglais généraliste <code>laya</code> séparait bien mieux ces défauts
(écarts de 52 à 66 points) que <code>laya-typed-decisions</code>. Cette instance ne charge que
<code>laya-typed-decisions</code> pour rester légère : ces trois contrôles y sont donc moins fiables.`}
Un fait est considéré présent si sa probabilité atteint ${pct(spec.threshold)}.</p>
<div class="scroll"><table>
<thead><tr><th>Contrôle</th><th>Affirmation envoyée au modèle</th><th>Réussi si</th>${liveHead}</tr></thead>
<tbody>${spec.checks.map((c, i) => `<tr><td>${esc(c.label)}</td><td class="en">${esc(c.instructions)}</td>
<td>${c.good_when ? "vrai" : "faux"}</td>${liveCell(r ? r.checks[i].p : null)}</tr>`).join("")}</tbody>
</table></div>
<p>Comme dans le cas 1, les affirmations portent sur <b>ce qui est écrit</b> (« contient un pourcentage »)
plutôt que sur un jugement (« l'email est-il convaincant ? »).</p>
<h3><span class="step">Étape 2</span>Quelle prochaine étape l'email propose-t-il ? <code>choice</code>, ${spec.next_step.options.length} options</h3>
<div class="quote">${esc(spec.next_step.instructions)}</div>
<p>Toutes les affirmations oui/non testées pour l'appel à l'action échouaient. Un choix entre plusieurs
demandes possibles fonctionne mieux, avec <code>laya-typed-decisions</code> : le contrôle est réussi si
l'option la plus probable est « ${esc(spec.next_step.options.find(o => o.good).label)} ». Il reste
<b>approximatif</b> : environ 8 emails de test sur 10 étaient bien classés.</p>
${optionsTable(spec.next_step.options, d && d.steps, { rowClass: o => o.good ? "lead" : "" })}
<h3><span class="step">Garde-fous</span>Est-ce bien un email commercial ?</h3>
<p>Deux questions ne changent pas le résultat mais affichent un avertissement.</p>
<p><b>Cohérence</b> (<code>noul</code>, comme au cas 1) : « ${esc(spec.coherence.instructions)} »
Sous ${pct(spec.coherence.threshold)}, le texte est signalé comme incompréhensible.
En cours : <span class="live">${d ? pct(d.coherence) : "-"}</span>.</p>
<p><b>Type de texte</b> (<code>choice</code>) : « ${esc(spec.sales.instructions)} » Si « sales email » est
sous ${pct(spec.sales.threshold)}, le texte est signalé comme n'étant pas un email commercial.</p>
${optionsTable(salesOptions, d && d.text_kind)}
<p>Ce filtre ne bloque pas les contrôles, car il distingue mal un email commercial vague ou timide d'un
texte hors-sujet : il repère les salutations et messages personnels, mais laisse passer une recette.</p>
<h3>Calcul final</h3>
<pre>chaque contrôle réussi compte 1 point, sur ${spec.checks.length + 1}</pre>
${r ? `<p>Appliqué au <span class="live">texte en cours</span> :</p><pre>${esc(reviewCalc(spec, r))}</pre>` : ""}`;
}
// =====================================================================
// Case 3: the Sorting Hat
// =====================================================================
const HOUSE_COLORS = { gryffindor: "#b3261e", ravenclaw: "#2a5db0", hufflepuff: "#c99a06", slytherin: "#1f7a4d" };
const HESITATION = 0.10;
const sortingSummary = (r) => `${r.house[0].label}, ${r.role[0].label.toLowerCase()}, ${r.language[0].label}`;
function sortingResult(r) {
if (!r) return `<div class="verdict">Décris-toi en quelques lignes : tes goûts, ton caractère, ce qui te motive.</div>`;
const [h1, h2] = r.house;
return `<div class="verdict" style="margin:0">Le Choixpeau a parlé :</div>
<div class="house" style="color:${HOUSE_COLORS[h1.key]}">${esc(h1.label)}</div>
<div class="hesit">${h1.p - h2.p < HESITATION ? `Il a hésité avec ${esc(h2.label)}...` : "Sans hésitation."}</div>
${r.house.map(h => {
const p = Math.round(h.p * 100);
return `<div class="sig"><span>${esc(h.label)}</span>
<div class="bar"><div style="width:${p}%;background:${HOUSE_COLORS[h.key]}"></div></div><span>${p}%</span></div>`;
}).join("")}
<div class="extra">
<div>Dans un braquage : <b>${esc(r.role[0].label)}</b><span>puis ${esc(r.role[1].label.toLowerCase())} (${pct(r.role[1].p)})</span></div>
<div>En langage de programmation : <b>${esc(r.language[0].label)}</b><span>puis ${esc(r.language[1].label)} (${pct(r.language[1].p)})</span></div>
</div>`;
}
function sortingExplain(spec, r) {
const probs = (key) => r ? Object.fromEntries(r[key].map(o => [o.key, o.p])) : null;
const section = (key, step, title, why) => `
<h3><span class="step">${step}</span>${title} <code>choice</code>, ${spec[key].options.length} options</h3>
<div class="quote">${esc(spec[key].instructions)}</div>
${why}
${optionsTable(spec[key].options, probs(key))}`;
const calc = r ? ["house", "role", "language"].map(k => {
const [a, b] = r[k];
return `${({ house: "Maison", role: "Braquage", language: "Langage" })[k].padEnd(9)}: ${a.label} (${p1(a.p)}) devant ${b.label} (${p1(b.p)})`;
}).join("\n") + `\n\nÉcart maison : ${p1(r.house[0].p - r.house[1].p)} ${r.house[0].p - r.house[1].p < HESITATION ? "<" : "≥"} ${pct(HESITATION)} → ${r.house[0].p - r.house[1].p < HESITATION ? "« Il a hésité »" : "« Sans hésitation »"}` : "";
return `<p>Un cas purement ludique, mais qui montre ce que Laya fait le mieux : <b>choisir parmi des options
nommées</b>. Pas de seuil ni de note, juste trois questions à choix, toutes envoyées à
<code>laya-typed-decisions</code>.</p>
${intro(3)}
${currentBox("Description", r, sortingSummary)}
${section("house", "Question 1", "Quelle maison de Poudlard ?",
`<p>Chaque maison est décrite par ses valeurs. Sur 4 profils types (fonceur, rat de bibliothèque, ami
fidèle, ambitieux), le modèle trouvait la bonne maison 4 fois sur 4.</p>`)}
${section("role", "Question 2", "Quel rôle dans un braquage ?",
`<p>Avec des descriptions courtes et abstraites (« leader stratégique »), presque tous les profils
devenaient « le cerveau ». Décrire <b>ce que fait la personne pendant le braquage</b> donne des réponses
bien plus variées.</p>`)}
${section("language", "Question 3", "Quel langage de programmation ?",
`<p>Chaque langage est décrit par une personnalité. Une description trop large (« présent partout »)
attirait tous les profils vers JavaScript : chaque option doit évoquer un trait distinct.</p>`)}
<h3>Calcul final</h3>
<pre>verdict = option la plus probable de chaque question
« Il a hésité » si les deux premières maisons sont à moins de ${pct(HESITATION)} d'écart</pre>
${r ? `<p>Appliqué au <span class="live">texte en cours</span> :</p><pre>${esc(calc)}</pre>` : ""}`;
}
// =====================================================================
// Cases and wiring
// =====================================================================
const CASES = {
prospect: {
title: "1. Qualité du prospect",
placeholder: "Colle ou écris un email reçu d'un prospect...",
result: prospectResult, explain: prospectExplain,
examples: {
"Spam": "Félicitations ! Vous avez gagné un iPhone 17. Cliquez ici pour récupérer votre cadeau avant minuit.",
"Démarchage fournisseur": "Bonjour, nous sommes une agence de génération de leads. Seriez-vous disponible 15 minutes pour découvrir comment nous pouvons doubler vos ventes ?",
"Étudiant curieux": "Bonjour, je suis étudiant et je fais un mémoire sur l'IA. Est-ce que je pourrais vous poser quelques questions ? Merci !",
"Gérant de PME, sans budget": "Bonjour, je gère une PME de 15 personnes. On pense à refaire notre site vitrine un jour, rien d'urgent et pas encore de budget. Vous faites ce genre de projet ?",
"Directrice commerciale, projet flou": "Bonjour, je suis directrice commerciale d'une PME industrielle de 60 personnes. Nous cherchons un CRM pour suivre nos commerciaux. Le budget n'est pas encore validé et nous n'avons pas de date. Pouvez-vous nous présenter votre offre ?",
"CFO avec échéance": "Bonjour, je suis CFO d'une scale-up de 120 personnes. Nous devons remplacer notre outil de reporting avant la clôture de décembre et nous avons prévu une enveloppe de 40k€. Pouvons-nous échanger cette semaine ?",
"DG, budget validé et urgence": "Bonjour, je suis directrice générale d'un groupe de distribution de 1 200 salariés. Notre plateforme e-commerce plante à chaque pic de ventes et nous perdons du chiffre d'affaires. Le conseil a approuvé un budget de 250k€ pour la refondre, je suis seule décisionnaire et la mise en ligne doit se faire avant le Black Friday. Pouvez-vous démarrer dans deux semaines ? Appelez-moi dès que possible.",
},
},
review: {
title: "2. Relecture avant envoi",
placeholder: "Écris l'email de prospection que tu t'apprêtes à envoyer...",
result: reviewResult, explain: reviewExplain,
examples: {
"Brochure générique": "Bonjour, nous sommes une agence digitale leader sur notre marché. Nous proposons des sites web, du SEO, des applications mobiles, du marketing digital et bien plus encore. N'hésitez pas à consulter notre site pour en savoir plus. Cordialement.",
"Trop insistant": "Bonjour, DERNIÈRE CHANCE !!! Notre offre exceptionnelle expire ce soir. Vous devez absolument nous appeler aujourd'hui, sinon vous allez perdre des clients face à vos concurrents. Répondez immédiatement.",
"Trop timide": "Bonjour Madame Martin, désolé de vous déranger, je ne sais pas si cela peut vous intéresser, mais peut-être qu'éventuellement notre outil pourrait vous être utile un jour, si vous avez le temps. Pas de souci si ce n'est pas le cas, encore désolé.",
"Sans prochaine étape": "Bonjour Madame Martin, j'ai vu que Boulangeries Martin venait d'ouvrir sa cinquième boutique à Lyon, félicitations ! Avec autant de points de vente, la gestion des commandes en ligne devient vite un casse-tête. Nous avons aidé une chaîne de boulangeries similaire à réduire de 30 % les erreurs de commande grâce à un outil de click and collect.",
"Excellent": "Bonjour Madame Martin, j'ai vu que Boulangeries Martin venait d'ouvrir sa cinquième boutique à Lyon, félicitations ! Avec autant de points de vente, la gestion des commandes en ligne devient vite un casse-tête. Nous avons aidé une chaîne de boulangeries similaire à réduire de 30 % les erreurs de commande grâce à un outil de click and collect. Seriez-vous disponible 15 minutes jeudi ou vendredi pour que je vous montre comment cela fonctionnerait chez vous ?",
},
},
sorting: {
title: "3. Le Choixpeau",
placeholder: "Décris-toi en quelques lignes : ton caractère, ce que tu aimes, ce qui te motive...",
result: sortingResult, explain: sortingExplain,
examples: {
"Fonceur": "Je fonce toujours tête baissée, j'adore l'aventure et je défends les autres même quand j'ai peur. Je préfère agir que réfléchir.",
"Rat de bibliothèque": "Je passe mes soirées à lire des livres de philosophie et à résoudre des énigmes. J'adore apprendre et comprendre comment les choses fonctionnent.",
"Ami fidèle": "Je suis fidèle à mes amis, patient et travailleur. Je préfère aider les autres et je n'aime pas être au centre de l'attention.",
"Ambitieux": "Je veux réussir à tout prix et devenir le patron. Je suis stratège, ambitieux et je sais utiliser les bonnes personnes pour atteindre mes objectifs.",
"Dev rigoureux": "Je suis très rigoureux, je déteste les bugs et j'aime que tout soit prouvé et typé. La performance compte énormément pour moi.",
"Artiste": "Je suis rêveur et créatif, je dessine, j'écris des poèmes et je trouve de la beauté partout. Je déteste les règles.",
"Bavard": "Je peux convaincre n'importe qui de n'importe quoi, je parle beaucoup et je me fais des amis partout où je vais.",
},
},
};
let current = "prospect", seq = 0, timer;
const state = Object.fromEntries(Object.keys(CASES).map(k => [k, { text: "", result: null, spec: null }]));
function renderAll() {
const c = CASES[current], st = state[current], r = st.result;
$("result").innerHTML = c.result(r);
$("ms").textContent = r ? `${Math.round(r.ms)} ms` : "-";
$("tin").textContent = r ? r.input_tokens : "-";
$("tout").textContent = r ? r.output_tokens : "-";
$("models").textContent = r ? `Modèle${r.models.length > 1 ? "s" : ""} : ${r.models.map(m => m.split("/")[1]).join(", ")}` : "";
$("case-body").innerHTML = st.spec ? c.explain(st.spec, r) : "<p>Chargement...</p>";
}
function selectCase(key) {
state[current].text = text.value;
current = key;
const c = CASES[key];
document.querySelectorAll("#cases button").forEach(b => b.setAttribute("aria-selected", b.dataset.case === key));
text.placeholder = c.placeholder;
text.value = state[key].text;
$("examples").innerHTML = "";
for (const [name, value] of Object.entries(c.examples)) {
const b = document.createElement("button");
b.textContent = name;
b.onclick = () => { text.value = value; schedule(0); };
$("examples").append(b);
}
if (!state[key].spec) {
fetch(`${API}/cases/${key}`).then(r => r.json()).then(s => { state[key].spec = s; if (current === key) renderAll(); });
}
renderAll();
}
for (const [key, c] of Object.entries(CASES)) {
const b = document.createElement("button");
b.dataset.case = key;
b.textContent = c.title;
b.onclick = () => selectCase(key);
$("cases").append(b);
}
async function run() {
const value = text.value.trim(), key = current;
if (!value) return;
const id = ++seq;
const res = await fetch(`${API}/predict`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ case: key, text: value }),
});
const data = await res.json();
if (id !== seq) return; // ignore responses that arrive out of order
state[key].result = { ...data, text: value };
if (current === key) renderAll();
}
// Wait for a pause in typing before calling the model; a CPU server needs a longer pause.
const DEBOUNCE = window.LAYA_DEBOUNCE || 150;
function schedule(delay = DEBOUNCE) { clearTimeout(timer); timer = setTimeout(run, delay); }
text.addEventListener("input", () => schedule());
selectCase("prospect");
fetch(`${API}/health`).then(r => r.json()).then(h => {
$("instance").innerHTML = `<b>Cette instance</b> : moteur ${h.backend === "mlx" ? "laya-mlx (MLX)" : "Laya d'origine (PyTorch)"},
modèle${h.models.length > 1 ? "s" : ""} ${h.models.map(m => `<code>${esc(m)}</code>`).join(" et ")}.`;
}).catch(() => {});
</script>
</body>
</html>