wilenPyxi
Audit pedagogique des declinaisons (qualite au-dela du harnais mecanique)
54b6073
Raw
History Blame Contribute Delete
46 kB
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Pythonise Exercice — v2</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #f7f7f8;
--panel: #ffffff;
--panel-alt: #fafafb;
--panel-hover: #f4f4f5;
--border: #e4e4e7;
--border-soft: #eeeef0;
--border-strong: #d4d4d8;
--text: #09090b;
--text-2: #27272a;
--text-muted: #71717a;
--text-soft: #a1a1aa;
--primary: #18181b;
--primary-hover: #27272a;
--primary-fg: #fafafa;
--accent: #2563eb;
--success: #16a34a;
--success-bg: #f0fdf4;
--success-border:#bbf7d0;
--error: #dc2626;
--error-bg: #fef2f2;
--error-border: #fecaca;
--warn: #ca8a04;
--warn-bg: #fefce8;
--sans: 'Geist', -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, sans-serif;
--mono: 'Geist Mono', 'JetBrains Mono', "SF Mono", Menlo, monospace;
--radius: 10px;
--radius-sm: 6px;
--radius-xs: 4px;
--shadow-xs: 0 1px 2px rgba(9, 9, 11, 0.04);
--shadow-sm: 0 1px 2px rgba(9, 9, 11, 0.04), 0 2px 8px rgba(9, 9, 11, 0.03);
--shadow-focus: 0 0 0 3px rgba(37, 99, 235, 0.12);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
[hidden] { display: none !important; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 14px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
.container { max-width: 1320px; margin: 0 auto; padding: 40px 32px 56px; }
.header {
display: flex; align-items: center; justify-content: space-between; gap: 16px;
margin-bottom: 28px; padding-bottom: 24px;
border-bottom: 1px solid var(--border-soft); flex-wrap: wrap;
}
.header-left { display: flex; align-items: center; gap: 14px; }
.header-mark {
width: 36px; height: 36px; border-radius: 8px;
background: var(--primary); color: var(--primary-fg);
display: flex; align-items: center; justify-content: center;
font-family: var(--mono); font-weight: 500; font-size: 13px;
}
.header-text h1 { margin: 0 0 2px; font-size: 17px; font-weight: 600; }
.header-text p { margin: 0; color: var(--text-muted); font-size: 13px; }
.header-status {
display: inline-flex; align-items: center; gap: 8px; font-size: 12px;
color: var(--text-muted); font-weight: 500; padding: 6px 12px;
background: var(--panel); border: 1px solid var(--border); border-radius: 999px;
}
.header-status .dot {
width: 6px; height: 6px; border-radius: 50%; background: var(--success);
box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.15);
}
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
@media (max-width: 960px) { .grid { grid-template-columns: 1fr; } }
.panel {
background: var(--panel); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow-sm);
padding: 24px; display: flex; flex-direction: column; min-width: 0;
}
.panel-title {
font-size: 11px; font-weight: 600; color: var(--text-muted);
margin: 0 0 16px; letter-spacing: 0.08em; text-transform: uppercase;
}
.field { margin-bottom: 14px; }
.field-label {
display: block; font-size: 12px; font-weight: 500; color: var(--text-2);
margin-bottom: 6px;
}
textarea, input[type="text"], select {
width: 100%; background: var(--panel); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 10px 12px; font-family: inherit;
font-size: 13px; line-height: 1.55; color: var(--text);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
textarea {
font-family: var(--mono); font-size: 12.5px; line-height: 1.6;
height: 320px; resize: vertical; min-height: 160px;
}
textarea:focus, input[type="text"]:focus, select:focus {
outline: none; border-color: var(--accent); box-shadow: var(--shadow-focus);
}
select {
appearance: none; -webkit-appearance: none;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><path fill='none' stroke='%2371717a' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M3 5 L6 8 L9 5'/></svg>");
background-repeat: no-repeat; background-position: right 10px center;
background-size: 12px; padding-right: 32px; cursor: pointer; font-weight: 500;
}
.meta { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px 14px; margin: 18px 0 20px; }
.meta-full { grid-column: 1 / -1; }
.actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.btn {
display: inline-flex; align-items: center; gap: 6px; padding: 9px 16px;
border-radius: var(--radius-sm); border: 1px solid var(--border);
background: var(--panel); color: var(--text); font-family: inherit;
font-size: 13px; font-weight: 500; cursor: pointer;
transition: all 0.15s ease; box-shadow: var(--shadow-xs);
}
.btn:hover:not(:disabled) { background: var(--panel-hover); border-color: var(--border-strong); }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn--primary { background: var(--primary); border-color: var(--primary); color: var(--primary-fg); }
.btn--primary:hover:not(:disabled) { background: var(--primary-hover); }
.btn--ghost { background: transparent; border-color: transparent; color: var(--text-muted); box-shadow: none; padding: 7px 12px; }
.btn--ghost:hover:not(:disabled) { background: var(--panel-hover); color: var(--text); }
.btn--small { padding: 5px 11px; font-size: 12px; }
/* Batch file list */
.filelist { list-style: none; padding: 0; margin: 10px 0 0; }
.filelist li {
display: flex; align-items: center; gap: 8px;
font-family: var(--mono); font-size: 12px; color: var(--text-2);
padding: 5px 8px; border: 1px solid var(--border-soft);
border-radius: var(--radius-xs); margin-bottom: 4px; background: var(--panel-alt);
}
.filelist .rm { margin-left: auto; cursor: pointer; color: var(--text-soft); border: none; background: none; font-size: 13px; }
.filelist .rm:hover { color: var(--error); }
.progress { margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--border-soft); }
.progress-line { display: flex; align-items: center; gap: 10px; font-size: 13px; color: var(--text-2); }
.progress-spinner {
width: 12px; height: 12px; border: 1.5px solid var(--border-strong);
border-top-color: var(--text); border-radius: 50%;
animation: spin 0.7s linear infinite; flex-shrink: 0;
}
.progress-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.progress-step {
font-family: var(--mono); font-size: 11px; color: var(--text-muted);
padding: 2px 7px; background: var(--bg); border: 1px solid var(--border-soft);
border-radius: var(--radius-xs); white-space: nowrap;
}
.progress-bar { margin-top: 10px; height: 3px; background: var(--border-soft); border-radius: 3px; overflow: hidden; position: relative; }
.progress-bar-fill { position: absolute; inset: 0; background: var(--text); transform: translateX(-100%); border-radius: 3px; }
.progress.is-running .progress-bar-fill { animation: slide 1.6s cubic-bezier(0.4, 0, 0.2, 1) infinite; }
.progress.is-done .progress-bar-fill { transform: translateX(0); background: var(--success); }
.progress.is-error .progress-bar-fill { transform: translateX(0); background: var(--error); }
.progress.is-idle .progress-spinner, .progress.is-done .progress-spinner, .progress.is-error .progress-spinner { display: none; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes slide {
0% { transform: translateX(-100%) scaleX(0.35); }
50% { transform: translateX(0%) scaleX(0.65); }
100% { transform: translateX(100%) scaleX(0.35); }
}
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
.status {
margin-top: 12px; padding: 10px 14px; border-radius: var(--radius-sm);
font-size: 13px; border: 1px solid transparent; animation: fadeIn 0.2s ease-out;
display: flex; align-items: center; gap: 8px;
}
.status::before { content: ''; width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.status.is-error { background: var(--error-bg); border-color: var(--error-border); color: #991b1b; }
.status.is-error::before { background: var(--error); }
.status.is-done { background: var(--success-bg); border-color: var(--success-border); color: #166534; }
.status.is-done::before { background: var(--success); }
.status.is-warn { background: var(--warn-bg); border-color: #fde68a; color: #854d0e; }
.status.is-warn::before { background: var(--warn); }
/* Résumé batch + sélecteur de fichier résultat */
.result-files { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
.result-file {
display: inline-flex; align-items: center; gap: 6px;
font-family: var(--mono); font-size: 11.5px; padding: 4px 10px;
border: 1px solid var(--border); border-radius: 999px;
background: var(--panel); cursor: pointer; color: var(--text-2);
}
.result-file.is-active { border-color: var(--text); background: var(--panel-hover); }
.verdict-dot { width: 7px; height: 7px; border-radius: 50%; }
.verdict-dot.ok { background: var(--success); }
.verdict-dot.ko { background: var(--error); }
.verdict-dot.err { background: var(--warn); }
.tabs {
display: flex; align-items: center; gap: 2px; margin-bottom: 14px;
padding: 4px; background: var(--bg); border: 1px solid var(--border-soft);
border-radius: var(--radius-sm); flex-wrap: wrap;
}
.tab {
background: transparent; border: none; padding: 6px 12px; font-family: inherit;
font-size: 13px; font-weight: 500; color: var(--text-muted); cursor: pointer;
border-radius: var(--radius-xs); transition: all 0.15s ease;
}
.tab.is-active { color: var(--text); background: var(--panel); box-shadow: var(--shadow-xs); }
.tab-badge {
display: inline-block; margin-left: 5px; padding: 0 6px; min-width: 18px;
height: 16px; font-size: 10px; font-family: var(--mono); font-weight: 500;
background: var(--text); color: var(--primary-fg); border-radius: 8px;
line-height: 16px; text-align: center; vertical-align: 1px;
}
.tab-spacer { flex: 1; }
.tab-panels { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.tab-panel { display: none; flex: 1; min-height: 0; flex-direction: column; }
.tab-panel.is-active { display: flex; animation: fadeIn 0.18s ease-out; }
.output {
background: var(--panel-alt); border: 1px solid var(--border-soft);
border-radius: var(--radius-sm); padding: 16px 18px; margin: 0;
font-family: var(--mono); font-size: 12.5px; line-height: 1.65;
color: var(--text-2); min-height: 380px; max-height: 620px;
overflow: auto; white-space: pre-wrap; word-break: break-word;
}
.output .empty { color: var(--text-soft); font-family: var(--sans); font-size: 13px; }
.output--audit { font-family: var(--sans); font-size: 13px; line-height: 1.6; white-space: normal; color: var(--text); }
.output::-webkit-scrollbar { width: 10px; height: 10px; }
.output::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; border: 2px solid var(--panel-alt); }
.audit-section + .audit-section { margin-top: 24px; }
.audit-heading {
font-size: 11px; font-weight: 600; color: var(--text-muted);
text-transform: uppercase; letter-spacing: 0.08em; margin: 0 0 12px;
display: flex; align-items: center; gap: 8px;
}
.audit-heading .count {
color: var(--text-soft); font-weight: 500; font-family: var(--mono);
font-size: 10px; padding: 1px 6px; background: var(--bg); border-radius: 999px;
}
.audit-list { list-style: none; padding: 0; margin: 0; }
.audit-patch { padding: 14px 0; border-top: 1px solid var(--border-soft); }
.audit-patch:first-child { border-top: none; padding-top: 0; }
.audit-rule {
display: inline-flex; font-family: var(--mono); font-size: 11px; font-weight: 500;
color: var(--text-2); background: var(--bg); border: 1px solid var(--border);
padding: 2px 8px; border-radius: var(--radius-xs); margin-bottom: 10px;
}
.audit-diff { display: grid; grid-template-columns: 1fr; gap: 6px; }
.audit-before, .audit-after {
font-family: var(--mono); font-size: 12px; padding: 10px 12px; margin: 0;
border-radius: var(--radius-xs); white-space: pre-wrap; line-height: 1.55;
border-left: 2px solid;
}
.audit-before { background: var(--error-bg); border-left-color: var(--error); color: #991b1b; text-decoration: line-through; }
.audit-after { background: var(--success-bg); border-left-color: var(--success); color: #166534; }
.audit-warnings { list-style: none; padding: 0; margin: 0; }
.audit-warnings li {
padding: 10px 0 10px 18px; border-top: 1px solid var(--border-soft);
position: relative; color: var(--text-2); font-size: 13px;
}
.audit-warnings li:first-child { border-top: none; }
.audit-warnings li::before {
content: ''; position: absolute; left: 0; top: 17px; width: 5px; height: 5px;
border-radius: 50%; background: var(--warn); box-shadow: 0 0 0 3px rgba(202, 138, 4, 0.15);
}
.audit-warnings .rule-ref {
display: inline-block; font-family: var(--mono); font-size: 11px; font-weight: 500;
color: var(--text-muted); background: var(--bg); padding: 1px 6px;
border-radius: var(--radius-xs); margin-right: 6px;
}
/* Bandeau verdict + coût */
.meta-result {
display: flex; gap: 8px; flex-wrap: wrap; align-items: center;
margin: 0 0 12px;
}
.pill {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12px; font-weight: 600; padding: 4px 12px; border-radius: 999px;
border: 1px solid;
}
.pill--ok { color: #166534; background: var(--success-bg); border-color: var(--success-border); }
.pill--ko { color: #991b1b; background: var(--error-bg); border-color: var(--error-border); }
.pill--info { color: var(--text-2); background: var(--bg); border-color: var(--border); font-weight: 500; font-family: var(--mono); font-size: 11.5px; }
.token.comment { color: #9ca3af; font-style: italic; }
.token.keyword, .token.boolean, .token.important { color: #9333ea; font-weight: 500; }
.token.string, .token.attr-value { color: #15803d; }
.token.function, .token.class-name { color: #1d4ed8; }
.token.number, .token.constant { color: #c2410c; }
.token.operator, .token.punctuation { color: #52525b; }
.token.title, .token.heading { font-weight: 600; color: var(--text); }
.footer {
margin-top: 40px; padding-top: 20px; border-top: 1px solid var(--border-soft);
text-align: center; font-size: 12px; color: var(--text-soft);
}
.footer code {
font-family: var(--mono); font-size: 11px; padding: 1px 6px;
background: var(--panel); border: 1px solid var(--border-soft);
border-radius: var(--radius-xs); color: var(--text-muted);
}
</style>
</head>
<body>
<div class="container">
<header class="header">
<div class="header-left">
<div class="header-mark">Py</div>
<div class="header-text">
<h1>Pythonise Exercice</h1>
<p>PyxiScience MyST → version pythonisée · validée au harnais</p>
</div>
</div>
<div class="header-status"><span class="dot"></span> service actif</div>
</header>
<div class="grid">
<!-- INPUT PANEL -->
<section class="panel">
<h2 class="panel-title">Source</h2>
<div class="field">
<textarea id="input" placeholder="Collez ici l'énoncé MyST à pythoniser… (ou chargez un/des fichiers .md ci-dessous)"></textarea>
</div>
<ul class="filelist" id="filelist" hidden></ul>
<div class="meta">
<div>
<label class="field-label" for="mode-select">Mode</label>
<select id="mode-select" onchange="onModeChange()">
<option value="pythonise">Pythonisation</option>
<option value="declinaisons">Déclinaisons (QCM/QAT)</option>
</select>
</div>
<div id="decl-types" hidden>
<label class="field-label">Types de déclinaison</label>
<div style="display:flex;gap:14px;padding:9px 2px;font-size:13px;">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
<input type="checkbox" id="type-qcm" checked onchange="updateRunLabel()"> QCM
</label>
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
<input type="checkbox" id="type-qat" onchange="updateRunLabel()"> QAT
</label>
</div>
</div>
<div>
<label class="field-label" for="policy-select">Politique de modèle</label>
<select id="policy-select" onchange="onPolicyChange()">
<option value="auto">Auto (l'app décide)</option>
<option value="best">Meilleur (qualité max)</option>
<option value="cheap">Économique</option>
<option value="manual">Manuel</option>
</select>
</div>
<div id="manual-models" class="meta-full" hidden>
<label class="field-label">Modèles par rôle (mode Manuel)</label>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;">
<div><label class="field-label" for="model-generate">Génération</label>
<select id="model-generate"></select></div>
<div><label class="field-label" for="model-audit">Audit</label>
<select id="model-audit"></select></div>
<div><label class="field-label" for="model-mecanique">Mécanique</label>
<select id="model-mecanique"></select></div>
</div>
</div>
<div>
<label class="field-label" for="lang-select">Langue cible</label>
<select id="lang-select">
<option value="fr">Français</option>
<option value="en">Anglais</option>
<option value="both">Les deux (bilingue)</option>
</select>
</div>
<div>
<label class="field-label" for="level">Niveau</label>
<select id="level">
<option value=""></option>
<option value="Elementary">Élémentaire</option>
<option value="Intermediate">Intermédiaire</option>
<option value="Advanced">Avancé</option>
</select>
</div>
<div class="meta-full">
<label class="field-label" for="filename">Nom du fichier <span style="opacity:0.6;font-weight:400">(si saisie manuelle)</span></label>
<input type="text" id="filename" value="exercise.md" placeholder="exercise.md">
</div>
</div>
<div class="actions">
<button class="btn btn--primary" id="run-btn" type="button" onclick="run()">Lancer la pythonisation</button>
<button class="btn" id="stop-btn" type="button" onclick="stopJob()" hidden
style="border-color:#c0392b;color:#c0392b;">⏹ Arrêter</button>
<button class="btn" type="button" onclick="document.getElementById('file-input').click()">Charger des fichiers .md</button>
<input type="file" id="file-input" accept=".md,.txt,text/markdown,text/plain" multiple hidden onchange="loadFiles(event)">
<button class="btn btn--ghost" type="button" onclick="clearAll()">Effacer</button>
</div>
<div class="progress is-idle" id="progress">
<div class="progress-line">
<span class="progress-spinner"></span>
<span class="progress-label" id="step-label">En attente</span>
<span class="progress-step" id="step-counter"></span>
</div>
<div class="progress-bar"><span class="progress-bar-fill"></span></div>
</div>
<div id="status" class="status" hidden></div>
</section>
<!-- OUTPUT PANEL -->
<section class="panel">
<h2 class="panel-title">Résultat</h2>
<div class="result-files" id="result-files" hidden></div>
<div class="meta-result" id="meta-result" hidden></div>
<nav class="tabs" id="tabs">
<button class="tab is-active" type="button" data-tab="exercice">Exercice</button>
<button class="tab" type="button" data-tab="audit">Audit<span class="tab-badge" id="audit-badge" hidden>0</span></button>
<button class="tab" type="button" data-tab="analysis">Analyse</button>
<button class="tab" type="button" data-tab="notions">Notions</button>
<span class="tab-spacer"></span>
<button class="btn btn--ghost btn--small" id="dl-btn" type="button" onclick="downloadActiveMd()" disabled>Télécharger .md</button>
<button class="btn btn--ghost btn--small" id="dlzip-btn" type="button" onclick="downloadZip()" hidden>Tout (.zip)</button>
<button class="btn btn--ghost btn--small" id="copy-btn" type="button" onclick="copyActive()" disabled>Copier</button>
</nav>
<div class="tab-panels">
<div class="tab-panel is-active" id="panel-exercice">
<pre class="output" id="output-exercice"><code class="empty language-markdown">L'exercice pythonisé apparaîtra ici.</code></pre>
</div>
<div class="tab-panel" id="panel-audit">
<div class="output output--audit" id="output-audit"><p class="empty">Aucun audit pour le moment.</p></div>
</div>
<div class="tab-panel" id="panel-analysis">
<pre class="output" id="output-analysis"><code class="empty language-json">{}</code></pre>
</div>
<div class="tab-panel" id="panel-notions">
<pre class="output" id="output-notions"><code class="empty"></code></pre>
</div>
</div>
</section>
</div>
<footer class="footer">
<code>v2</code> · Pythonisation PyxiScience · porte harnais déterministe · OpenRouter
</footer>
</div>
<script src="https://unpkg.com/prismjs@1.29.0/components/prism-core.min.js"></script>
<script src="https://unpkg.com/prismjs@1.29.0/components/prism-markup.min.js"></script>
<script src="https://unpkg.com/prismjs@1.29.0/components/prism-python.min.js"></script>
<script src="https://unpkg.com/prismjs@1.29.0/components/prism-json.min.js"></script>
<script src="https://unpkg.com/prismjs@1.29.0/components/prism-markdown.min.js"></script>
<script>
'use strict';
const POLL_MS = 1000;
const POLL_SLOW_AFTER = 12; // au-delà : mode lent (10 s) — on n'abandonne PAS
const POLL_SLOW_MS = 10000;
const POLL_GIVEUP_MS = 45 * 60 * 1000; // plafond absolu de reconnexion (45 min)
let pollFirstFailureAt = null;
let currentJobId = null;
let pollTimer = null;
let pollFailures = 0;
let activeTab = "exercice";
let loadedFiles = []; // [{filename, content}]
let jobResults = []; // résultats par fichier
let activeResultIdx = 0;
document.addEventListener("DOMContentLoaded", () => {
fetchModels(); bindTabs();
// Reprise après rechargement : si un job était en cours, on se raccroche
// (le serveur le garde JOB_TTL=30 min après sa fin ; 404 → suivi perdu).
const saved = localStorage.getItem("pyxi_job_id");
if (saved) {
currentJobId = saved;
document.getElementById("run-btn").disabled = true;
const sb = document.getElementById("stop-btn");
sb.hidden = false; sb.disabled = false; sb.textContent = "⏹ Arrêter";
setProgress("running", "Reprise du suivi du job en cours…", "…");
poll();
}
});
async function fetchModels() {
try {
const res = await fetch("/api/models");
if (!res.ok) return;
const { default_lang, default_policy, catalog, recommended } = await res.json();
// Dropdowns par rôle (mode Manuel), alimentés par le catalogue (sans Fable).
const roleDefaults = {
generate: recommended?.generate?.cheap,
audit: recommended?.audit?.best,
mecanique: recommended?.mecanique?.cheap,
};
for (const role of ["generate", "audit", "mecanique"]) {
const sel = document.getElementById(`model-${role}`);
if (!sel || !catalog?.[role]) continue;
sel.innerHTML = "";
for (const key of catalog[role]) {
const opt = document.createElement("option");
opt.value = key;
opt.textContent = prettyModelName(key);
if (key === roleDefaults[role]) opt.selected = true;
sel.appendChild(opt);
}
}
if (default_policy) document.getElementById("policy-select").value = default_policy;
if (default_lang) document.getElementById("lang-select").value = default_lang;
onPolicyChange();
} catch (e) { console.warn("/api/models:", e); }
}
function prettyModelName(slug) {
const name = slug.includes("/") ? slug.split("/")[1] : slug;
return name
.replace(/^claude-/, "Claude ").replace(/^gpt-/, "GPT-")
.replace(/^gemini-/, "Gemini ").replace(/-/g, " ")
.replace(/\b\w/g, c => c.toUpperCase()).replace(/Gpt/g, "GPT");
}
/* ─── Mode (pythonisation / déclinaisons) + politique de modèle ─── */
function onModeChange() {
const decl = document.getElementById("mode-select").value === "declinaisons";
document.getElementById("decl-types").hidden = !decl;
updateRunLabel();
}
function onPolicyChange() {
const manual = document.getElementById("policy-select").value === "manual";
document.getElementById("manual-models").hidden = !manual;
}
function updateRunLabel() {
const btn = document.getElementById("run-btn");
if (document.getElementById("mode-select").value !== "declinaisons") {
btn.textContent = "Lancer la pythonisation";
return;
}
const qcm = document.getElementById("type-qcm").checked;
const qat = document.getElementById("type-qat").checked;
const what = (qcm && qat) ? "QCM + QAT" : qat ? "QAT" : qcm ? "QCM" : "…";
btn.textContent = `Générer les déclinaisons ${what}`;
}
/* ─── Tabs ─── */
function bindTabs() {
document.querySelectorAll("#tabs .tab").forEach(btn => {
btn.addEventListener("click", () => switchTab(btn.dataset.tab));
});
}
function switchTab(name) {
activeTab = name;
document.querySelectorAll("#tabs .tab").forEach(btn =>
btn.classList.toggle("is-active", btn.dataset.tab === name));
document.querySelectorAll(".tab-panel").forEach(panel =>
panel.classList.toggle("is-active", panel.id === `panel-${name}`));
updateCopyState();
}
function activeDoneResult() {
const e = jobResults[activeResultIdx];
return (e && e.status === "done" && e.result) ? e : null;
}
function updateCopyState() {
// Copier (panneau actif)
const text = currentPanelText();
document.getElementById("copy-btn").disabled =
!text || !text.trim() || text.trim() === "—" || text.trim() === "{}";
// Télécharger .md (résultat affiché)
const r = activeDoneResult();
const dl = document.getElementById("dl-btn");
if (dl) dl.disabled = !(r && r.result.exercise);
// Tout (.zip) — seulement en batch (≥ 2 fichiers prêts)
const doneCount = jobResults.filter(x => x.status === "done").length;
const zip = document.getElementById("dlzip-btn");
if (zip) zip.hidden = !(doneCount >= 2 && currentJobId);
}
function triggerDownload(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function downloadActiveMd() {
const r = activeDoneResult();
if (!r || !r.result.exercise) return;
const base = (r.filename || "exercice").replace(/\.(md|txt)$/i, "");
triggerDownload(
new Blob([r.result.exercise], { type: "text/markdown;charset=utf-8" }),
base + "_pythonise.md"
);
}
function downloadZip() {
if (!currentJobId) return;
const a = document.createElement("a");
a.href = `/api/jobs/${currentJobId}/download`;
a.download = ""; // nom fixé par le serveur (Content-Disposition)
document.body.appendChild(a); a.click(); a.remove();
}
function currentPanelText() {
const map = { exercice: "output-exercice", audit: "output-audit",
analysis: "output-analysis", notions: "output-notions" };
const el = document.getElementById(map[activeTab]);
if (!el) return "";
if (activeTab === "audit") return el.innerText || "";
const code = el.querySelector("code");
return code ? code.textContent : (el.textContent || "");
}
/* ─── Progress + status ─── */
function setProgress(state, label, step) {
const prog = document.getElementById("progress");
prog.classList.remove("is-idle", "is-running", "is-done", "is-error");
prog.classList.add(`is-${state}`);
if (label !== undefined) document.getElementById("step-label").textContent = label;
if (step !== undefined) document.getElementById("step-counter").textContent = step;
}
function setStatus(state, text) {
const el = document.getElementById("status");
el.className = "status" + (state ? ` is-${state}` : "");
el.textContent = text;
el.hidden = false;
}
function hideStatus() { document.getElementById("status").hidden = true; }
/* ─── Fichiers (batch) ─── */
function loadFiles(event) {
const files = Array.from(event.target.files || []);
if (!files.length) return;
let pending = files.length;
files.forEach(file => {
const reader = new FileReader();
reader.onload = (e) => {
loadedFiles.push({ filename: file.name, content: String(e.target.result || "") });
if (--pending === 0) renderFileList();
};
reader.onerror = () => { if (--pending === 0) renderFileList(); };
reader.readAsText(file, "UTF-8");
});
event.target.value = "";
}
function renderFileList() {
const ul = document.getElementById("filelist");
ul.innerHTML = "";
ul.hidden = loadedFiles.length === 0;
loadedFiles.forEach((f, i) => {
const li = document.createElement("li");
li.innerHTML = `📄 ${escapeHtml(f.filename)} <span style="color:var(--text-soft)">(${(f.content.length / 1024).toFixed(1)} ko)</span>
<button class="rm" title="Retirer" onclick="removeFile(${i})">✕</button>`;
ul.appendChild(li);
});
}
function removeFile(i) { loadedFiles.splice(i, 1); renderFileList(); }
/* ─── Rendu des sorties ─── */
function setExerciseOutput(text) {
const pre = document.getElementById("output-exercice");
pre.innerHTML = "";
const code = document.createElement("code");
code.className = "language-markdown";
if (!text) { code.classList.add("empty"); code.textContent = "L'exercice pythonisé apparaîtra ici."; }
else { code.textContent = text; if (window.Prism) Prism.highlightElement(code); }
pre.appendChild(code);
updateCopyState();
}
function setAnalysisOutput(obj) {
const pre = document.getElementById("output-analysis");
pre.innerHTML = "";
const code = document.createElement("code");
code.className = "language-json";
if (!obj) { code.classList.add("empty"); code.textContent = "{}"; }
else { code.textContent = JSON.stringify(obj, null, 2); if (window.Prism) Prism.highlightElement(code); }
pre.appendChild(code);
}
function setNotionsOutput(text) {
const pre = document.getElementById("output-notions");
pre.innerHTML = "";
const code = document.createElement("code");
if (!text || text === "—") { code.classList.add("empty"); code.textContent = "—"; }
else { code.textContent = text; }
pre.appendChild(code);
}
function setAuditOutput(result) {
const container = document.getElementById("output-audit");
const patches = result.audit_patches || [];
const warnings = result.warnings || [];
const badge = document.getElementById("audit-badge");
if (!patches.length && !warnings.length) {
container.innerHTML = `<p class="empty">Aucun patch ni warning — l'audit a validé l'exercice.</p>`;
badge.hidden = true;
return;
}
badge.hidden = false;
badge.textContent = String(patches.length + warnings.length);
let html = "";
if (patches.length) {
html += `<section class="audit-section"><h3 class="audit-heading">Patches appliqués <span class="count">${patches.length}</span></h3><ul class="audit-list">`;
for (const p of patches) {
html += `<li class="audit-patch">
<span class="audit-rule">règle ${escapeHtml(p.rule || "?")}</span>
<div class="audit-diff">
<pre class="audit-before"><code>${escapeHtml(p.location || "")}</code></pre>
<pre class="audit-after"><code>${escapeHtml(p.fix || "")}</code></pre>
</div></li>`;
}
html += `</ul></section>`;
}
if (warnings.length) {
html += `<section class="audit-section"><h3 class="audit-heading">Warnings <span class="count">${warnings.length}</span></h3><ul class="audit-warnings">`;
for (const w of warnings) {
let ruleRef = "", txt = w;
if (typeof w === "object" && w !== null) {
if (w.rule) ruleRef = `<span class="rule-ref">règle ${escapeHtml(w.rule)}</span>`;
txt = w.message || JSON.stringify(w);
}
html += `<li>${ruleRef}${escapeHtml(String(txt))}</li>`;
}
html += `</ul></section>`;
}
container.innerHTML = html;
}
/* Bandeau verdict + coût + langue pour le fichier affiché */
function setMetaResult(entry) {
const el = document.getElementById("meta-result");
if (!entry) { el.hidden = true; return; }
el.hidden = false;
if (entry.status === "error") {
el.innerHTML = `<span class="pill pill--ko">✗ Erreur pipeline</span>
<span class="pill pill--info">${escapeHtml(entry.error || "")}</span>`;
return;
}
const r = entry.result;
const h = r.harness || {};
const verdict = h.ok
? `<span class="pill pill--ok">✓ Harnais VERT (${h.seeds} graines)</span>`
: `<span class="pill pill--ko">✗ Harnais ROUGE (${h.seeds} graines)</span>`;
// Audit pédagogique (déclinaisons) : qualité au-delà du harnais mécanique.
let ped = "";
const p = r.pedagogical;
if (p && p.verdict) {
if (p.verdict === "OK") {
const sc = (p.score != null) ? ` (${p.score}/100)` : "";
ped = `<span class="pill pill--ok">✓ Qualité pédagogique${sc}</span>`;
} else if (p.verdict === "A_REVOIR") {
const n = (p.issues || []).length;
ped = `<span class="pill pill--ko">⚠ Qualité pédagogique à revoir (${n})</span>`;
} else {
ped = `<span class="pill pill--info">audit pédagogique indisponible</span>`;
}
}
const cost = r.cost ? `<span class="pill pill--info">${r.cost.usd.toFixed(4)} $ · ${r.cost.requests} appels</span>` : "";
const lang = r.lang ? `<span class="pill pill--info">langue : ${escapeHtml(r.lang.source)}${escapeHtml(r.lang.target)} (${escapeHtml(r.lang.action)})</span>` : "";
const dur = r.duration_s ? `<span class="pill pill--info">${r.duration_s}s</span>` : "";
let decl = "";
if (r.decl_type) {
const label = r.decl_type === "qcm" ? "QCM" : "QAT";
decl = `<span class="pill pill--info">déclinaison : ${label}</span>`;
if (r.decl_type === "qat" && (r.exercise || "").includes(":questionType: MCQ")) {
decl += `<span class="pill pill--info">repli MCQ partiel</span>`;
}
}
let pol = "";
const t = r.policy_telemetry;
if (t) {
const esc = (t.tried || []).length > 1 ? ` (${t.tried.length} échelons)` : "";
pol = `<span class="pill pill--info">modèle : ${escapeHtml(prettyModelName(t.winning_model || r.model_used || "?"))}${esc} · ${escapeHtml(t.mode)}/${escapeHtml(t.difficulty)}</span>`;
if (t.needs_review) pol += `<span class="pill pill--ko">revue humaine requise</span>`;
} else if (r.model_used) {
pol = `<span class="pill pill--info">modèle : ${escapeHtml(prettyModelName(r.model_used))}</span>`;
}
el.innerHTML = verdict + ped + decl + pol + cost + lang + dur;
}
/* Sélecteur de fichier (batch) */
function renderResultFiles() {
const el = document.getElementById("result-files");
if (jobResults.length <= 1) { el.hidden = true; return; }
el.hidden = false;
el.innerHTML = "";
jobResults.forEach((r, i) => {
const cls = r.status === "error" ? "err" : (r.result?.harness?.ok ? "ok" : "ko");
const span = document.createElement("span");
span.className = "result-file" + (i === activeResultIdx ? " is-active" : "");
span.innerHTML = `<span class="verdict-dot ${cls}"></span>${escapeHtml(r.filename)}`;
span.onclick = () => { activeResultIdx = i; showResult(i); renderResultFiles(); };
el.appendChild(span);
});
}
function showResult(i) {
const entry = jobResults[i];
if (!entry) return;
setMetaResult(entry);
if (entry.status === "error") {
setExerciseOutput(""); setAnalysisOutput(null); setNotionsOutput(""); setAuditOutput({});
return;
}
const r = entry.result;
setExerciseOutput(r.exercise || "");
setAnalysisOutput(r.analysis || null);
setNotionsOutput(r.notions || "—");
setAuditOutput(r);
}
/* ─── Run + poll ─── */
async function run() {
let files = loadedFiles.slice();
const manual = document.getElementById("input").value.trim();
if (!files.length && manual) {
files = [{ filename: document.getElementById("filename").value.trim() || "exercise.md",
content: manual }];
}
if (!files.length) { setStatus("error", "Saisissez un énoncé ou chargez des fichiers .md."); return; }
const mode = document.getElementById("mode-select").value;
const policyMode = document.getElementById("policy-select").value;
const payload = {
files,
level: document.getElementById("level").value,
lang: document.getElementById("lang-select").value,
mode,
policy: policyMode,
};
if (policyMode === "manual") {
payload.models = {
generate: document.getElementById("model-generate").value,
audit: document.getElementById("model-audit").value,
mecanique: document.getElementById("model-mecanique").value,
};
}
if (mode === "declinaisons") {
payload.types = {
qcm: document.getElementById("type-qcm").checked,
qat: document.getElementById("type-qat").checked,
};
if (!payload.types.qcm && !payload.types.qat) {
setStatus("error", "Mode Déclinaisons : coche au moins un type (QCM ou QAT).");
return;
}
}
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
currentJobId = null;
pollFailures = 0;
jobResults = []; activeResultIdx = 0;
document.getElementById("run-btn").disabled = true;
const stopBtn = document.getElementById("stop-btn");
stopBtn.hidden = false; stopBtn.disabled = false;
stopBtn.textContent = "⏹ Arrêter";
hideStatus();
setExerciseOutput(""); setAnalysisOutput(null); setNotionsOutput(""); setAuditOutput({});
setMetaResult(null);
document.getElementById("result-files").hidden = true;
document.getElementById("audit-badge").hidden = true;
switchTab("exercice");
setProgress("running", "Soumission du job…", "0/" + files.length);
try {
const res = await fetch("/api/jobs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || `HTTP ${res.status}`);
}
currentJobId = (await res.json()).job_id;
localStorage.setItem("pyxi_job_id", currentJobId);
poll();
} catch (e) {
setProgress("error", "Échec", "—");
setStatus("error", "Erreur : " + e.message);
jobFinished();
}
}
/* Fin de job (quel que soit l'état terminal) : run réactivé, stop masqué. */
function jobFinished() {
document.getElementById("run-btn").disabled = false;
document.getElementById("stop-btn").hidden = true;
localStorage.removeItem("pyxi_job_id");
}
/* Arrêt coopératif : le serveur stoppe au prochain point d'étape (l'appel
LLM en cours se termine, rien ne s'enchaîne) ; résultats partiels gardés. */
async function stopJob() {
if (!currentJobId) return;
const stopBtn = document.getElementById("stop-btn");
stopBtn.disabled = true;
stopBtn.textContent = "Arrêt en cours…";
try {
const res = await fetch(`/api/jobs/${currentJobId}/cancel`, { method: "POST" });
if (res.status === 404 || res.status === 409) {
// Job déjà terminé/perdu : le prochain poll affichera l'état final.
stopBtn.hidden = true;
return;
}
setStatus("warn", "Arrêt demandé — fin de l'étape en cours…");
} catch (e) {
stopBtn.disabled = false;
stopBtn.textContent = "⏹ Arrêter";
setStatus("error", "Impossible de demander l'arrêt : " + e.message);
}
}
async function poll() {
if (!currentJobId) return;
try {
const res = await fetch(`/api/jobs/${currentJobId}`, { headers: { "Accept": "application/json" } });
// Lire en texte d'abord : sous forte charge, le proxy HF peut renvoyer une
// page HTML (502/504) au lieu du JSON → on traite ça comme un hoquet
// transitoire et on RÉESSAIE, au lieu de tuer tout le suivi.
const raw = await res.text();
let data;
try { data = JSON.parse(raw); }
catch (_) { throw new Error(`réponse non-JSON (HTTP ${res.status})`); }
if (res.status === 404) {
// Job inconnu : le serveur a probablement redémarré (perte du suivi en mémoire).
setProgress("error", "Suivi perdu", "—");
setStatus("error", "Le serveur a perdu ce job (redémarrage). Le traitement est interrompu — relance.");
jobFinished();
return;
}
pollFailures = 0; // réponse valide → on repart à zéro
pollFirstFailureAt = null;
jobResults = data.results || [];
if (data.status === "running") {
setProgress("running", data.step_label || "…", `${data.files_done}/${data.files_total}`);
if (jobResults.length && activeResultIdx < jobResults.length) {
renderResultFiles(); showResult(Math.min(activeResultIdx, jobResults.length - 1));
}
pollTimer = setTimeout(poll, POLL_MS);
} else if (data.status === "done") {
const s = data.summary || {};
setProgress(s.rouges || s.erreurs ? "error" : "done", "Terminé", `${data.files_done}/${data.files_total}`);
const msg = `Terminé : ${s.verts ?? 0} VERT(S), ${s.rouges ?? 0} ROUGE(S), ${s.erreurs ?? 0} erreur(s) · ${s.cost_usd ?? 0} $`;
setStatus(s.rouges || s.erreurs ? "warn" : "done", msg);
activeResultIdx = 0;
renderResultFiles();
showResult(0);
jobFinished();
} else if (data.status === "cancelled") {
const s = data.summary || {};
setProgress("error", "Arrêté", `${data.files_done}/${data.files_total}`);
setStatus("warn",
`Génération arrêtée. Résultats partiels conservés : ${s.verts ?? 0} VERT(S), `
+ `${s.rouges ?? 0} ROUGE(S), ${s.erreurs ?? 0} erreur(s) · ${s.cost_usd ?? 0} $`);
if (jobResults.length) { activeResultIdx = 0; renderResultFiles(); showResult(0); }
jobFinished();
} else if (data.status === "error") {
setProgress("error", "Erreur", "—");
setStatus("error", "Erreur : " + (data.error || "inconnue"));
jobFinished();
}
} catch (e) {
// Erreur TRANSITOIRE : pendant les phases de calcul (harnais 100 graines),
// le serveur mono-process peut être injoignable PLUSIEURS MINUTES — le job
// continue. On ne renonce donc JAMAIS avant le plafond absolu : backoff
// léger, puis mode lent (10 s) ; le suivi se recale à la 1re réponse.
pollFailures++;
if (!pollFirstFailureAt) pollFirstFailureAt = Date.now();
if (Date.now() - pollFirstFailureAt > POLL_GIVEUP_MS) {
setProgress("error", "Connexion interrompue", "—");
setStatus("error",
"Suivi injoignable depuis 45 min (" + e.message + "). "
+ "Recharge la page ; si le serveur a redémarré, relance le job.");
jobFinished();
return;
}
const slow = pollFailures >= POLL_SLOW_AFTER;
setStatus("warn",
slow
? "Serveur occupé (phase de calcul) — le traitement CONTINUE côté serveur, "
+ `reconnexion toutes les 10 s… (${Math.round((Date.now() - pollFirstFailureAt) / 1000)} s)`
: `Reconnexion au suivi… (${pollFailures})`);
pollTimer = setTimeout(
poll, slow ? POLL_SLOW_MS : Math.min(6000, POLL_MS * (1 + pollFailures)));
}
}
function clearAll() {
if (pollTimer) clearTimeout(pollTimer);
currentJobId = null;
localStorage.removeItem("pyxi_job_id");
document.getElementById("stop-btn").hidden = true;
document.getElementById("run-btn").disabled = false;
loadedFiles = []; jobResults = []; activeResultIdx = 0;
renderFileList();
document.getElementById("input").value = "";
setExerciseOutput(""); setAnalysisOutput(null); setNotionsOutput(""); setAuditOutput({});
setMetaResult(null);
document.getElementById("result-files").hidden = true;
document.getElementById("audit-badge").hidden = true;
setProgress("idle", "En attente", "—");
hideStatus();
document.getElementById("run-btn").disabled = false;
}
function copyActive() {
const text = currentPanelText();
if (!text) return;
navigator.clipboard.writeText(text).then(() => {
const btn = document.getElementById("copy-btn");
const old = btn.textContent;
btn.textContent = "Copié";
setTimeout(() => { btn.textContent = old; }, 1400);
});
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#039;");
}
</script>
</body>
</html>