diff --git a/App/Controller/IaController.php b/App/Controller/IaController.php new file mode 100644 index 00000000..a85a0697 --- /dev/null +++ b/App/Controller/IaController.php @@ -0,0 +1,457 @@ +authService = new AuthenticationService(new SessionService()); + } + + /** + * Show the IA page with "Cartographie des codes" tab + */ + public function index(): void + { + $this->authService->requireAuth('/auth/login'); + + $pdo = DatabaseConnection::getInstance()->getConnection(); + + // Stats globales + $totalAttempts = (int)$pdo->query("SELECT COUNT(*) FROM attempts")->fetchColumn(); + $totalExercises = (int)$pdo->query("SELECT COUNT(*) FROM exercices")->fetchColumn(); + $totalStudents = (int)$pdo->query("SELECT COUNT(DISTINCT user_id) FROM attempts")->fetchColumn(); + + // Répartition par eval_set + $evalSets = $pdo->query( + "SELECT eval_set, COUNT(*) AS count FROM attempts" + . " WHERE eval_set IS NOT NULL GROUP BY eval_set ORDER BY eval_set" + )->fetchAll(\PDO::FETCH_ASSOC); + + // Ressources + $resources = $pdo->query( + "SELECT ressource_id, ressource_name FROM ressources ORDER BY ressource_name ASC" + )->fetchAll(\PDO::FETCH_ASSOC); + + // Exercices (tous, pour le sélecteur dynamique côté JS) + $exercises = $pdo->query( + "SELECT e.exercice_id, e.exercice_name AS exercise_name, e.ressource_id, r.ressource_name, + COUNT(a.attempt_id) AS nb_attempts + FROM exercices e + LEFT JOIN ressources r ON e.ressource_id = r.ressource_id + LEFT JOIN attempts a ON a.exercice_id = e.exercice_id AND a.aes2 IS NOT NULL AND a.aes2 != '' + GROUP BY e.exercice_id + ORDER BY r.ressource_name ASC, e.exercice_name ASC" + )->fetchAll(\PDO::FETCH_ASSOC); + + $this->renderView('user/ia', [ + 'stats' => [ + 'total_attempts' => $totalAttempts, + 'total_exercises' => $totalExercises, + 'total_students' => $totalStudents, + 'eval_sets' => $evalSets, + ], + 'resources' => $resources, + 'exercises' => $exercises, + ]); + } + + /** + * API endpoint : GET /api/ia/status?resource_id=X + * Vérifie si des tentatives avec AES existent pour la ressource (prêtes pour l'IA). + */ + public function status(): void + { + if (!$this->authService->isAuthenticated()) { + $this->jsonError('Non authentifié', 401); + return; + } + + try { + $resourceId = isset($_GET['resource_id']) ? (int)$_GET['resource_id'] : null; + $exerciseId = isset($_GET['exercise_id']) ? (int)$_GET['exercise_id'] : null; + + $pdo = DatabaseConnection::getInstance()->getConnection(); + + if ($exerciseId) { + // Statut micro : nombre de tentatives AES pour un exercice + $stmt = $pdo->prepare(" + SELECT COUNT(*) FROM attempts a + JOIN exercices e ON a.exercice_id = e.exercice_id + WHERE a.exercice_id = :eid + AND a.aes2 IS NOT NULL AND a.aes2 != '' + "); + $stmt->execute(['eid' => $exerciseId]); + $count = (int)$stmt->fetchColumn(); + + $this->jsonResponse([ + 'success' => true, + 'available' => $count >= 5, + 'aes_count' => $count, + 'exercise_id' => $exerciseId, + 'message' => $count >= 5 + ? "Données IA disponibles ($count tentatives avec AES)." + : "Pas assez de données AES ($count/5 minimum).", + ]); + } elseif ($resourceId) { + // Statut macro : nombre de tentatives AES pour la ressource + $stmt = $pdo->prepare(" + SELECT COUNT(*) FROM attempts a + JOIN exercices e ON a.exercice_id = e.exercice_id + WHERE e.ressource_id = :rid + AND a.aes2 IS NOT NULL AND a.aes2 != '' + "); + $stmt->execute(['rid' => $resourceId]); + $count = (int)$stmt->fetchColumn(); + + // Nombre d'exercices avec AES + $stmt2 = $pdo->prepare(" + SELECT COUNT(DISTINCT e.exercice_id) FROM attempts a + JOIN exercices e ON a.exercice_id = e.exercice_id + WHERE e.ressource_id = :rid + AND a.aes2 IS NOT NULL AND a.aes2 != '' + "); + $stmt2->execute(['rid' => $resourceId]); + $exCount = (int)$stmt2->fetchColumn(); + + $this->jsonResponse([ + 'success' => true, + 'available' => $count >= 5, + 'aes_count' => $count, + 'exercise_count' => $exCount, + 'resource_id' => $resourceId, + 'message' => $count >= 5 + ? "Données IA disponibles ($count tentatives AES, $exCount exercices)." + : "Pas assez de données AES ($count/5 minimum).", + ]); + } else { + $this->jsonError('resource_id ou exercise_id requis.'); + } + } catch (\Throwable $e) { + $this->jsonError('Erreur serveur : ' . $e->getMessage(), 500); + } + } + + /** + * API endpoint : POST /api/ia/macro + * Vue Macro : t-SNE global sur TOUTES les tentatives, centroïdes par TD. + */ + public function macro(): void + { + if (!$this->authService->isAuthenticated()) { + $this->jsonError('Non authentifié', 401); + return; + } + + if (!$this->isPost()) { + $this->jsonError('Méthode non autorisée', 405); + return; + } + + try { + $input = json_decode(file_get_contents('php://input'), true); + $perplexity = (int)($input['perplexity'] ?? 30); + $resourceId = isset($input['resource_id']) ? (int)$input['resource_id'] : null; + + $pdo = DatabaseConnection::getInstance()->getConnection(); + + // Extraire TOUTES les tentatives avec AES (filtrées éventuellement par ressource) + $sql = " + SELECT + a.attempt_id, + a.aes2, + a.eval_set, + a.correct, + a.user_id AS user_id, + a.exercice_id AS exercice_id, + e.exercice_name AS exercise_name + FROM attempts a + JOIN exercices e ON a.exercice_id = e.exercice_id + WHERE a.aes2 IS NOT NULL AND a.aes2 != '' + "; + $params = []; + + if ($resourceId) { + $sql .= " AND e.ressource_id = :rid"; + $params['rid'] = $resourceId; + } + + $sql .= " ORDER BY a.attempt_id"; + + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + $attempts = $stmt->fetchAll(\PDO::FETCH_ASSOC); + + if (count($attempts) < 5) { + $this->jsonError( + 'Pas assez de tentatives avec AES pour la vue globale (' + . count($attempts) . ' trouvées, minimum 5).' + ); + return; + } + + $payload = json_encode([ + 'mode' => 'global', + 'attempts' => $attempts, + 'perplexity' => $perplexity, + ], JSON_UNESCAPED_UNICODE); + + $result = $this->runPythonPipeline($payload); + $this->jsonResponse($result); + } catch (\Throwable $e) { + $this->jsonError('Erreur serveur : ' . $e->getMessage(), 500); + } + } + + /** + * API endpoint : POST /api/ia/micro + * Vue Micro : clustering K-Means + t-SNE pour UN exercice, avec trajectoires. + */ + public function micro(): void + { + if (!$this->authService->isAuthenticated()) { + $this->jsonError('Non authentifié', 401); + return; + } + + if (!$this->isPost()) { + $this->jsonError('Méthode non autorisée', 405); + return; + } + + try { + $input = json_decode(file_get_contents('php://input'), true); + $exerciseId = (int)($input['exercise_id'] ?? 0); + $nClusters = (int)($input['n_clusters'] ?? 8); + $perplexity = (int)($input['perplexity'] ?? 30); + + if ($exerciseId <= 0) { + $this->jsonError('exercise_id invalide'); + return; + } + + $pdo = DatabaseConnection::getInstance()->getConnection(); + + $stmt = $pdo->prepare(" + SELECT + a.attempt_id, + a.aes2, + a.eval_set, + a.correct, + a.user_id AS user_id, + a.exercice_id AS exercice_id, + NULL AS submission_date, + e.exercice_name AS exercise_name + FROM attempts a + JOIN exercices e ON a.exercice_id = e.exercice_id + WHERE a.exercice_id = :eid + AND a.aes2 IS NOT NULL + AND a.aes2 != '' + ORDER BY a.user_id, a.attempt_id + "); + $stmt->execute(['eid' => $exerciseId]); + $attempts = $stmt->fetchAll(\PDO::FETCH_ASSOC); + + if (count($attempts) < 5) { + $this->jsonError( + 'Pas assez de tentatives avec AES pour cet exercice (' + . count($attempts) . ' trouvées, minimum 5).' + ); + return; + } + + $payload = json_encode([ + 'mode' => 'micro', + 'attempts' => $attempts, + 'n_clusters' => $nClusters, + 'perplexity' => $perplexity, + 'exercise_id' => $exerciseId, + ], JSON_UNESCAPED_UNICODE); + + $result = $this->runPythonPipeline($payload); + $this->jsonResponse($result); + } catch (\Throwable $e) { + $this->jsonError('Erreur serveur : ' . $e->getMessage(), 500); + } + } + + /** + * Exécute le script Python clustering_pipeline.py avec un payload JSON via stdin. + * Retourne le résultat décodé (array). + */ + private function runPythonPipeline(string $payload): array + { + $projectRoot = realpath(__DIR__ . '/../../'); + $scriptPath = $projectRoot . '/scripts/clustering_pipeline.py'; + + $pythonPath = $this->findPython($projectRoot); + + if ($pythonPath === null) { + return [ + 'success' => false, + 'message' => 'Aucun interpréteur Python avec gensim trouvé. ' + . 'Vérifiez que gensim est installé : python3 -m pip install --user gensim', + ]; + } + + if (!file_exists($scriptPath)) { + return [ + 'success' => false, + 'message' => 'Script clustering_pipeline.py introuvable : ' . $scriptPath, + ]; + } + + $cmd = sprintf( + '%s %s --from-stdin', + escapeshellarg($pythonPath), + escapeshellarg($scriptPath) + ); + + $env = null; + if (PHP_OS_FAMILY !== 'Windows') { + $home = getenv('HOME') ?: '/home/studtraj'; + $env = [ + 'HOME' => $home, + 'PYTHONUSERBASE' => $home . '/.local', + 'PATH' => getenv('PATH') ?: '/usr/local/bin:/usr/bin:/bin', + 'PYTHONDONTWRITEBYTECODE' => '1', + ]; + } + + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $process = proc_open($cmd, $descriptors, $pipes, null, $env); + + if (!is_resource($process)) { + return [ + 'success' => false, + 'message' => 'Impossible de lancer le script Python (commande: ' . $cmd . ')', + ]; + } + + fwrite($pipes[0], $payload); + fclose($pipes[0]); + + $stdout = stream_get_contents($pipes[1]); + fclose($pipes[1]); + + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[2]); + + $exitCode = proc_close($process); + + // Chercher le JSON dans stdout puis stderr + $jsonStr = null; + foreach ([$stdout, $stderr, $stdout . $stderr] as $output) { + $jsonStart = strpos($output, '{'); + if ($jsonStart !== false) { + $candidate = substr($output, $jsonStart); + $decoded = json_decode($candidate, true); + if ($decoded !== null) { + $jsonStr = $candidate; + break; + } + } + } + + if ($jsonStr === null) { + $rawOutput = trim($stdout . "\n" . $stderr); + return [ + 'success' => false, + 'message' => 'Le script Python n\'a pas renvoyé de JSON valide (exit code: ' + . $exitCode . '). Sortie: ' . substr($rawOutput, 0, 800), + ]; + } + + return json_decode($jsonStr, true); + } + + /** + * Trouve un exécutable Python capable d'importer gensim. + * Teste les venv locaux, puis /usr/bin/python3, puis python3/python du PATH. + * Passe PYTHONUSERBASE pour que les packages pip --user soient visibles. + */ + private function findPython(string $projectRoot): ?string + { + // Candidats avec chemin absolu (file_exists testable) + $absoluteCandidates = [ + // Venv dans scripts/ + $projectRoot . '/scripts/venv/bin/python3', + $projectRoot . '/scripts/venv/bin/python', + $projectRoot . '/scripts/venv/Scripts/python.exe', + // Venv à la racine du projet + $projectRoot . '/venv/bin/python3', + $projectRoot . '/venv/bin/python', + // Venv dans le home (Alwaysdata) + '/home/studtraj/venv/bin/python3', + '/home/studtraj/www/venv/bin/python3', + '/home/studtraj/www/SAE/scripts/venv/bin/python3', + // Python système (chemin absolu) + '/usr/bin/python3', + // Windows + 'C:\\xampp\\htdocs\\BUT3\\venv\\Scripts\\python.exe', + ]; + + // D'abord tester les chemins absolus qui existent sur le disque + foreach ($absoluteCandidates as $candidate) { + if (file_exists($candidate) && $this->pythonHasGensim($candidate)) { + return $candidate; + } + } + + // Ensuite tester les commandes du PATH (pas testables avec file_exists) + foreach (['python3', 'python'] as $candidate) { + if ($this->pythonHasGensim($candidate)) { + return $candidate; + } + } + + return null; + } + + /** + * Vérifie qu'un exécutable Python donné peut importer gensim. + * Injecte PYTHONUSERBASE pour couvrir les installations pip --user. + */ + private function pythonHasGensim(string $pythonBin): bool + { + $home = getenv('HOME') ?: '/home/studtraj'; + $envPrefix = ''; + if (PHP_OS_FAMILY !== 'Windows') { + $envPrefix = sprintf( + 'HOME=%s PYTHONUSERBASE=%s ', + escapeshellarg($home), + escapeshellarg($home . '/.local') + ); + } + + $cmd = sprintf( + '%s%s -c %s 2>&1', + $envPrefix, + escapeshellarg($pythonBin), + escapeshellarg('import gensim') + ); + + $output = []; + $exitCode = -1; + exec($cmd, $output, $exitCode); + + return $exitCode === 0; + } +} diff --git a/App/View/resources/details.php b/App/View/resources/details.php index 22c32959..924d591b 100644 --- a/App/View/resources/details.php +++ b/App/View/resources/details.php @@ -149,6 +149,7 @@
diff --git a/App/View/user/dashboard.php b/App/View/user/dashboard.php index 427fc31a..c409128c 100644 --- a/App/View/user/dashboard.php +++ b/App/View/user/dashboard.php @@ -235,6 +235,8 @@
+ + + + +
+

Intelligence Artificielle

+

+ Analyse des trajectoires d'apprentissage par vectorisation Doc2Vec, + clustering K-Means et visualisation t-SNE. +

+ + +
+ + + +
+ + +
+ +
+
+

Tentatives

+
+
+
+

Exercices

+
+
+
+

Étudiants

+
+
+
+ +
+

Répartition des jeux de données

+ 0 ? round($s['count'] / $total * 100, 1) : 0; + ?> +
+ +
+ (%) +
+ +

Aucune donnée disponible. Importez des tentatives pour commencer.

+ +
+ +
+

Exercices analysables

+

+ Seuls les exercices dont les tentatives possèdent une séquence AES peuvent être analysés. +

+ + + + + + + + + + + + + + + + + + + + + +
ExerciceRessourceTentatives AESAction
+ + + + + + + + = 5) : ?> + + + Min. 5 tentatives + +
+ +

Aucun exercice trouvé.

+ +
+
+ + +
+
+

🗺️ Vue Macro — Cartographie globale des TDs

+

+ Visualisation t-SNE de toutes les tentatives regroupées par exercice (TD). + Chaque gros point représente le centroïde d'un TD. Cliquez sur un centroïde + pour zoomer sur la vue détaillée (Micro). +

+ +
+
+ + +
+
+ + +
+ +
+ + +
+
+
Analyse globale en cours…
+
Doc2Vec sur tout le dataset → t-SNE (peut prendre 30-60 secondes)
+
+ + +
+ + +
+
+
+
+
+

+ 💡 Cliquez sur un centroïde (gros point) pour ouvrir la vue détaillée de cet exercice. +

+
+
+
+ + +
+
+

🔬 Vue Micro — Analyse détaillée d'un exercice

+ + +

+ Clustering K-Means + t-SNE pour un exercice spécifique. + Les trajectoires montrent l'évolution chronologique de chaque étudiant + (lignes fléchées reliant les tentatives successives). +

+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ + +
+ + +
+
+
Analyse détaillée en cours…
+
Doc2Vec → K-Means → t-SNE + trajectoires (10-30 secondes)
+
+ + +
+ + +
+
+
+
+
+

+ 🔍 Survolez un point pour voir le détail. Les lignes pointillées relient les tentatives + successives d'un même étudiant (triées par date). + ● = correct, ✗ = incorrect. +

+
+
+
+
+ + + + + + + + + + diff --git a/App/routes.php b/App/routes.php index 3073af8c..331c039e 100644 --- a/App/routes.php +++ b/App/routes.php @@ -33,6 +33,17 @@ // Dashboard routes (protected) $router->get('/dashboard', App\Controller\DashboardController::class, 'index'); +// IA route +$router->get('/ia', App\Controller\IaController::class, 'index'); + +// IA API routes (vue Macro / Micro) +$router->post('/api/ia/macro', App\Controller\IaController::class, 'macro'); +$router->post('/api/ia/micro', App\Controller\IaController::class, 'micro'); + +// IA API : vérifier si des données AES existent pour une ressource +$router->get('/api/ia/status', App\Controller\IaController::class, 'status'); + + // Exercise routes $router->get('/exercises', App\Controller\ExercisesController::class, 'index'); $router->get('/exercises/{id}', App\Controller\ExercisesController::class, 'show'); diff --git a/index.php b/index.php index 26855639..e141237e 100644 --- a/index.php +++ b/index.php @@ -23,6 +23,12 @@ if (!headers_sent()) { http_response_code(500); + $uri = $_SERVER['REQUEST_URI'] ?? ''; + if (strpos($uri, '/api/') !== false) { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['success' => false, 'message' => 'Erreur fatale du serveur'], JSON_UNESCAPED_UNICODE); + return; + } } $uri = $_SERVER['REQUEST_URI'] ?? ''; @@ -98,6 +104,33 @@ echo '

Erreur interne du serveur

Une erreur est survenue. Veuillez réessayer.

'; } } + $uri = $_SERVER['REQUEST_URI'] ?? ''; + if (strpos($uri, '/api/') !== false) { + if (!headers_sent()) { + http_response_code(500); + header('Content-Type: application/json; charset=utf-8'); + } + $env = defined('APP_ENV') ? APP_ENV : (\Core\Config\EnvLoader::get('APP_ENV', 'production')); + $payload = ['success' => false, 'message' => 'Erreur interne du serveur']; + if ($env === 'development') { + $payload['message'] = get_class($e) . ': ' . $msg; + $payload['file'] = $file . ':' . $line; + $payload['trace'] = $e->getTraceAsString(); + } + echo json_encode($payload, JSON_UNESCAPED_UNICODE); + return; + } + + if (!headers_sent()) { + http_response_code(500); + header('Content-Type: text/html; charset=utf-8'); + } + + // TOUJOURS afficher les détails pour le debug + echo '

Erreur 500 – Exception non gérée

'; + echo '

' . htmlspecialchars(get_class($e)) . ': ' . htmlspecialchars($msg) . '

'; + echo '

dans ' . htmlspecialchars($file) . ' ligne ' . $line . '

'; + echo '
' . htmlspecialchars($e->getTraceAsString()) . '
'; }); // Initialize router @@ -130,6 +163,12 @@ function () { // Set 404 handler $router->setNotFoundHandler(function () { http_response_code(404); + $uri = $_SERVER['REQUEST_URI'] ?? ''; + if (strpos($uri, '/api/') !== false) { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['success' => false, 'message' => 'Route API non trouvée'], JSON_UNESCAPED_UNICODE); + return; + } if (file_exists(__DIR__ . '/App/View/errors/404.php')) { require __DIR__ . '/App/View/errors/404.php'; } else { diff --git a/phpcs.xml b/phpcs.xml index 413cfd4d..37473919 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -43,7 +43,7 @@ - + diff --git a/public/js/modules/iaIntegration.js b/public/js/modules/iaIntegration.js new file mode 100644 index 00000000..7c3c3b2c --- /dev/null +++ b/public/js/modules/iaIntegration.js @@ -0,0 +1,661 @@ +/** + * iaIntegration.js — Module d'intégration IA dans le dashboard (Macro/Micro) + * + * S'intègre dans le VizManager pour afficher : + * - Vue Macro (niveau 1) : bouton "Générer la cartographie IA" + graphe t-SNE global + * - Vue Micro (niveau 2B - TP) : graphe trajectoires par étudiant ou message d'info + */ + +const PLOTLY_CDN = 'https://cdn.plot.ly/plotly-2.32.0.min.js'; +const COLORS_10 = [ + '#3498db', '#e74c3c', '#2ecc71', '#f39c12', '#9b59b6', + '#1abc9c', '#e67e22', '#34495e', '#d35400', '#7f8c8d' +]; + +let _plotlyLoaded = false; +let _plotlyLoading = null; + +/** + * Charge Plotly.js dynamiquement si pas encore chargé. + */ +function loadPlotly() { + if (_plotlyLoaded || window.Plotly) { + _plotlyLoaded = true; + return Promise.resolve(); + } + if (_plotlyLoading) return _plotlyLoading; + + _plotlyLoading = new Promise((resolve, reject) => { + const s = document.createElement('script'); + s.src = PLOTLY_CDN; + s.onload = () => { _plotlyLoaded = true; resolve(); }; + s.onerror = () => reject(new Error('Impossible de charger Plotly.js')); + document.head.appendChild(s); + }); + return _plotlyLoading; +} + +function esc(str) { + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function formatDate(dateStr) { + if (!dateStr || dateStr === 'None' || dateStr === '') return '—'; + try { + const d = new Date(dateStr); + if (isNaN(d.getTime())) return dateStr; + return d.toLocaleDateString('fr-FR', { + day: '2-digit', month: 'short', year: 'numeric', + hour: '2-digit', minute: '2-digit', + }); + } catch (e) { return dateStr; } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// VUE MACRO — Section IA dans la vue globale de la ressource (niveau 1) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Rend la section Macro IA dans le conteneur donné. + * @param {HTMLElement} parentContainer — l'élément dans lequel injecter la section + * @param {number} resourceId — ID de la ressource + * @param {Function} onExerciseClick — callback(exerciseId, exerciseName) quand on clique un centroïde + */ +export async function renderMacroSection(parentContainer, resourceId, onExerciseClick) { + // Créer la carte IA + const card = document.createElement('div'); + card.className = 'viz-chart-card'; + card.id = 'viz-ia-macro-card'; + card.style.gridColumn = '1 / -1'; + card.innerHTML = ` +

🧠 Cartographie IA des TDs

+
+ Vérification des données IA… +
+ + + + + + `; + parentContainer.appendChild(card); + + // Ajouter l'animation CSS si pas encore présente + if (!document.getElementById('ia-spin-style')) { + const style = document.createElement('style'); + style.id = 'ia-spin-style'; + style.textContent = '@keyframes ia-spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }'; + document.head.appendChild(style); + } + + // Vérifier le statut IA + try { + const BASE = window.BASE_URL || ''; + const resp = await fetch(`${BASE}/api/ia/status?resource_id=${resourceId}`); + const data = await resp.json(); + + const statusEl = document.getElementById('ia-macro-status'); + const actionsEl = document.getElementById('ia-macro-actions'); + + if (data.success && data.available) { + statusEl.innerHTML = `✅ ${esc(data.message)}`; + actionsEl.style.display = 'block'; + + // Bouton de génération + const btn = document.getElementById('ia-macro-generate-btn'); + btn.addEventListener('click', () => { + _launchMacroGeneration(resourceId, onExerciseClick); + }); + } else { + statusEl.innerHTML = `ℹ️ ${esc(data.message || 'Aucune donnée AES disponible pour cette ressource.')}`; + statusEl.style.color = '#e67e22'; + } + } catch (err) { + const statusEl = document.getElementById('ia-macro-status'); + if (statusEl) statusEl.innerHTML = '⚠️ Impossible de vérifier le statut IA.'; + console.error('[iaIntegration] status check error:', err); + } +} + +/** + * Lance la génération Macro (appel POST /api/ia/macro). + */ +async function _launchMacroGeneration(resourceId, onExerciseClick) { + const actionsEl = document.getElementById('ia-macro-actions'); + const spinnerEl = document.getElementById('ia-macro-spinner'); + const errorEl = document.getElementById('ia-macro-error'); + const plotEl = document.getElementById('ia-macro-plot'); + const metaEl = document.getElementById('ia-macro-meta'); + const statusEl = document.getElementById('ia-macro-status'); + + if (actionsEl) actionsEl.style.display = 'none'; + if (errorEl) errorEl.style.display = 'none'; + if (plotEl) plotEl.style.display = 'none'; + if (spinnerEl) spinnerEl.style.display = 'block'; + if (statusEl) statusEl.innerHTML = '⏳ Génération en cours…'; + + try { + await loadPlotly(); + + const BASE = window.BASE_URL || ''; + const resp = await fetch(`${BASE}/api/ia/macro`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ resource_id: resourceId, perplexity: 30 }), + }); + + const ct = resp.headers.get('content-type') || ''; + if (!ct.includes('application/json')) { + throw new Error('Réponse non-JSON du serveur (HTTP ' + resp.status + ')'); + } + const data = await resp.json(); + + if (spinnerEl) spinnerEl.style.display = 'none'; + + if (data.success) { + statusEl.innerHTML = '✅ Cartographie IA générée avec succès !'; + _drawMacroPlot(data, plotEl, metaEl, onExerciseClick); + } else { + statusEl.innerHTML = '❌ Échec de la génération.'; + if (errorEl) { + errorEl.textContent = '❌ ' + (data.message || data.error || 'Erreur inconnue'); + errorEl.style.display = 'block'; + } + if (actionsEl) actionsEl.style.display = 'block'; + } + } catch (err) { + if (spinnerEl) spinnerEl.style.display = 'none'; + if (errorEl) { + errorEl.textContent = '❌ Erreur réseau : ' + err.message; + errorEl.style.display = 'block'; + } + if (actionsEl) actionsEl.style.display = 'block'; + if (statusEl) statusEl.innerHTML = '⚠️ Erreur lors de la génération.'; + console.error('[iaIntegration] macro generation error:', err); + } +} + +/** + * Dessine le graphe Macro (centroïdes + nuage) avec Plotly. + */ +function _drawMacroPlot(data, plotEl, metaEl, onExerciseClick) { + if (!plotEl) return; + plotEl.style.display = 'block'; + + const traces = []; + + // Nuage de fond (tous les points, colorés par exercice, faible opacité) + if (data.all_points && data.all_points.length > 0) { + const byExo = {}; + data.all_points.forEach(p => { + if (!byExo[p.exercise_name]) byExo[p.exercise_name] = { x: [], y: [], ids: [] }; + byExo[p.exercise_name].x.push(p.x); + byExo[p.exercise_name].y.push(p.y); + byExo[p.exercise_name].ids.push(p.exercice_id); + }); + + let colorIdx = 0; + Object.keys(byExo).forEach(exName => { + const grp = byExo[exName]; + const col = COLORS_10[colorIdx % COLORS_10.length]; + traces.push({ + x: grp.x, y: grp.y, + mode: 'markers', type: 'scatter', + name: exName, + marker: { size: 5, color: col, opacity: 0.2 }, + hoverinfo: 'text', + text: grp.x.map(() => exName), + showlegend: false, + customdata: grp.ids, + }); + colorIdx++; + }); + } + + // Centroïdes (gros points cliquables) + if (data.centroids && data.centroids.length > 0) { + const cx = data.centroids.map(c => c.x); + const cy = data.centroids.map(c => c.y); + const labels = data.centroids.map(c => c.exercise_name); + const sizes = data.centroids.map(c => Math.max(16, Math.min(45, 10 + Math.sqrt(c.n_attempts) * 3))); + const colors = data.centroids.map((_, i) => COLORS_10[i % COLORS_10.length]); + const ids = data.centroids.map(c => c.exercice_id); + const hovers = data.centroids.map(c => + `${esc(c.exercise_name)}
${c.n_attempts} tentatives
🔍 Cliquer pour détailler ce TD` + ); + + traces.push({ + x: cx, y: cy, + mode: 'markers+text', type: 'scatter', + name: 'Centroïdes TDs', + marker: { size: sizes, color: colors, line: { width: 2, color: '#fff' }, opacity: 0.9 }, + text: labels, + textposition: 'top center', + textfont: { size: 11, color: '#2c3e50', family: 'sans-serif' }, + hoverinfo: 'text', + hovertext: hovers, + customdata: ids, + showlegend: true, + }); + } + + const layout = { + title: { text: 'Cartographie globale des TDs (t-SNE)', font: { size: 15, color: '#2c3e50' } }, + xaxis: { title: 't-SNE dim. 1', zeroline: false, showgrid: true, gridcolor: '#ecf0f1' }, + yaxis: { title: 't-SNE dim. 2', zeroline: false, showgrid: true, gridcolor: '#ecf0f1' }, + hovermode: 'closest', + plot_bgcolor: '#fafbfc', + paper_bgcolor: '#fff', + margin: { t: 60, b: 50, l: 60, r: 30 }, + legend: { orientation: 'h', y: -0.15 }, + }; + + Plotly.newPlot(plotEl, traces, layout, { responsive: true }).then(() => { + // Clic sur un centroïde → navigation vers le TD (Vue Micro) + plotEl.on('plotly_click', function (evtData) { + if (!evtData || !evtData.points || evtData.points.length === 0) return; + const pt = evtData.points[0]; + const exerciseId = pt.customdata; + if (exerciseId && typeof exerciseId === 'number' && exerciseId > 0) { + const centroid = data.centroids.find(c => c.exercice_id === exerciseId); + const exerciseName = centroid ? centroid.exercise_name : ''; + if (typeof onExerciseClick === 'function') { + onExerciseClick(exerciseId, exerciseName); + } + } + }); + }); + + // Métadonnées + if (metaEl) { + metaEl.style.display = 'block'; + metaEl.innerHTML = + `${data.n_points} tentatives analysées` + + `${data.n_exercises} exercices (TDs)`; + } +} + + +// ═══════════════════════════════════════════════════════════════════════════════ +// VUE MICRO — Section IA dans la vue d'un TP (niveau 2B) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Rend la section Micro IA (trajectoires) dans le conteneur donné. + * @param {HTMLElement} parentContainer — l'élément parent + * @param {number} exerciseId — ID de l'exercice + * @param {string} exerciseName — Nom de l'exercice + */ +export async function renderMicroSection(parentContainer, exerciseId, exerciseName) { + const card = document.createElement('div'); + card.className = 'viz-chart-card'; + card.id = 'viz-ia-micro-card'; + card.style.gridColumn = '1 / -1'; + card.innerHTML = ` +

🧠 Cartographie des trajectoires IA — ${esc(exerciseName)}

+
+ Vérification des données IA… +
+ + + + + `; + parentContainer.appendChild(card); + + // Ajouter l'animation CSS si pas encore présente + if (!document.getElementById('ia-spin-style')) { + const style = document.createElement('style'); + style.id = 'ia-spin-style'; + style.textContent = '@keyframes ia-spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }'; + document.head.appendChild(style); + } + + // Vérifier si des données AES existent pour cet exercice + try { + const BASE = window.BASE_URL || ''; + const resp = await fetch(`${BASE}/api/ia/status?exercise_id=${exerciseId}`); + const statusData = await resp.json(); + + const statusEl = document.getElementById('ia-micro-status'); + + if (statusData.success && statusData.available) { + statusEl.innerHTML = '⏳ Chargement de la cartographie des trajectoires…'; + _launchMicroGeneration(exerciseId, exerciseName); + } else { + statusEl.innerHTML = ` +
+ 📌 Analyse IA non disponible pour ce TD
+ Veuillez générer l'analyse IA depuis la page de la ressource (vue globale). +
+ `; + } + } catch (err) { + const statusEl = document.getElementById('ia-micro-status'); + if (statusEl) statusEl.innerHTML = '⚠️ Impossible de vérifier le statut IA.'; + console.error('[iaIntegration] micro status check error:', err); + } +} + +/** + * Lance la génération Micro (POST /api/ia/micro). + */ +async function _launchMicroGeneration(exerciseId, exerciseName) { + const spinnerEl = document.getElementById('ia-micro-spinner'); + const errorEl = document.getElementById('ia-micro-error'); + const plotEl = document.getElementById('ia-micro-plot'); + const metaEl = document.getElementById('ia-micro-meta'); + const statusEl = document.getElementById('ia-micro-status'); + + if (spinnerEl) spinnerEl.style.display = 'block'; + if (errorEl) errorEl.style.display = 'none'; + + try { + await loadPlotly(); + + const BASE = window.BASE_URL || ''; + const resp = await fetch(`${BASE}/api/ia/micro`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ exercise_id: exerciseId, n_clusters: 8, perplexity: 30 }), + }); + + const ct = resp.headers.get('content-type') || ''; + if (!ct.includes('application/json')) { + throw new Error('Réponse non-JSON du serveur (HTTP ' + resp.status + ')'); + } + const data = await resp.json(); + + if (spinnerEl) spinnerEl.style.display = 'none'; + + if (data.success) { + if (statusEl) statusEl.innerHTML = '✅ Trajectoires IA chargées.'; + _drawMicroPlot(data, plotEl, metaEl); + } else { + if (statusEl) statusEl.innerHTML = ''; + if (errorEl) { + errorEl.innerHTML = ` +
+ 📌 Analyse IA non disponible pour ce TD
+ Veuillez générer l'analyse IA depuis la page de la ressource (vue globale).
+ ${esc(data.message || data.error || '')} +
+ `; + errorEl.style.display = 'block'; + errorEl.style.color = 'inherit'; + errorEl.style.background = 'none'; + errorEl.style.border = 'none'; + } + } + } catch (err) { + if (spinnerEl) spinnerEl.style.display = 'none'; + if (errorEl) { + errorEl.textContent = '❌ Erreur réseau : ' + err.message; + errorEl.style.display = 'block'; + } + console.error('[iaIntegration] micro generation error:', err); + } +} + +/** + * Dessine le graphe Micro (clusters + trajectoires + hover focus) avec Plotly. + */ +function _drawMicroPlot(data, plotEl, metaEl) { + if (!plotEl) return; + plotEl.style.display = 'block'; + + const points = data.points || []; + if (points.length === 0) return; + + const nClusters = data.n_clusters || 8; + const traces = []; + + const DIM_OPACITY = 0.15; + + // 1) Points colorés par cluster + for (let c = 0; c < nClusters; c++) { + const clusterPts = points.filter(p => p.cluster === c); + if (clusterPts.length === 0) continue; + + const col = COLORS_10[c % COLORS_10.length]; + + traces.push({ + x: clusterPts.map(p => p.x), + y: clusterPts.map(p => p.y), + mode: 'markers', type: 'scatter', + name: `Cluster ${c}`, + marker: { + size: clusterPts.map(p => p.correct ? 14 : 8), + color: col, + opacity: DIM_OPACITY, + line: { + width: clusterPts.map(p => p.correct ? 2.5 : 1), + color: clusterPts.map(p => p.correct ? '#FFD700' : '#fff'), + }, + symbol: clusterPts.map(p => p.correct ? 'star' : 'circle'), + }, + hoverinfo: 'text', + text: clusterPts.map(p => { + const dateStr = p.date && p.date !== 'None' ? formatDate(p.date) : '—'; + return `👤 Étudiant : ${esc(p.user_id)}
` + + `🎯 Cluster : ${p.cluster}
` + + `${p.correct ? '✅' : '❌'} Correct : ${p.correct ? 'Oui' : 'Non'}
` + + `📝 Tentative : #${p.attempt_id}
` + + `📅 Date : ${dateStr}`; + }), + customdata: clusterPts.map(p => p.user_id), + hoverlabel: { + bgcolor: '#2c3e50', bordercolor: '#ecf0f1', + font: { color: '#fff', size: 12, family: 'sans-serif' }, + }, + }); + } + + const nClusterTraces = traces.length; + + // 2) Trajectoires par étudiant (lignes) + const byUser = {}; + points.forEach(p => { + if (!byUser[p.user_id]) byUser[p.user_id] = []; + byUser[p.user_id].push(p); + }); + + const trajColors = [ + 'rgba(52,73,94,{a})', 'rgba(142,68,173,{a})', 'rgba(41,128,185,{a})', + 'rgba(39,174,96,{a})', 'rgba(243,156,18,{a})', 'rgba(192,57,43,{a})', + 'rgba(22,160,133,{a})', 'rgba(127,140,141,{a})', + ]; + + const trajectoryAnnotations = []; + let colIdx = 0; + + Object.keys(byUser).forEach(userId => { + let userPts = byUser[userId]; + if (userPts.length < 2) return; + + userPts.sort((a, b) => { + if (a.date && b.date && a.date !== b.date) return a.date.localeCompare(b.date); + return (a.attempt_id || 0) - (b.attempt_id || 0); + }); + + const colTemplate = trajColors[colIdx % trajColors.length]; + const dimCol = colTemplate.replace('{a}', String(DIM_OPACITY)); + colIdx++; + + traces.push({ + x: userPts.map(p => p.x), + y: userPts.map(p => p.y), + mode: 'lines', type: 'scatter', + name: `Traj. ${userId}`, + line: { color: dimCol, width: 1, dash: 'dot' }, + hoverinfo: 'skip', + showlegend: false, + customdata: userPts.map(() => userId), + _userId: userId, + _colTemplate: colTemplate, + }); + + // Flèches + for (let i = 0; i < userPts.length - 1; i++) { + const fromPt = userPts[i]; + const toPt = userPts[i + 1]; + const dx = toPt.x - fromPt.x; + const dy = toPt.y - fromPt.y; + if (Math.sqrt(dx * dx + dy * dy) < 0.3) continue; + + trajectoryAnnotations.push({ + x: toPt.x, y: toPt.y, + ax: fromPt.x, ay: fromPt.y, + xref: 'x', yref: 'y', axref: 'x', ayref: 'y', + showarrow: true, arrowhead: 3, arrowsize: 1.4, + arrowwidth: 1.5, + arrowcolor: colTemplate.replace('{a}', String(DIM_OPACITY)), + standoff: 5, startstandoff: 5, + opacity: DIM_OPACITY, + _userId: userId, + _colTemplate: colTemplate, + }); + } + }); + + const exName = data.exercise_name || ''; + const layout = { + title: { + text: `Cartographie des trajectoires — ${exName}
${data.n_points} tentatives, ${nClusters} clusters · Survolez pour isoler une trajectoire · ★ = réussite`, + font: { size: 14, color: '#2c3e50' }, + }, + xaxis: { title: 't-SNE dim. 1', zeroline: false, showgrid: true, gridcolor: '#ecf0f1' }, + yaxis: { title: 't-SNE dim. 2', zeroline: false, showgrid: true, gridcolor: '#ecf0f1' }, + hovermode: 'closest', + plot_bgcolor: '#fafbfc', + paper_bgcolor: '#fff', + margin: { t: 80, b: 50, l: 60, r: 30 }, + legend: { orientation: 'h', y: -0.18 }, + showlegend: true, + annotations: trajectoryAnnotations.length > 300 ? trajectoryAnnotations.slice(0, 300) : trajectoryAnnotations, + }; + + Plotly.newPlot(plotEl, traces, layout, { responsive: true }).then(() => { + // Hover focus : surbrillance trajectoire + plotEl.on('plotly_hover', function (evtData) { + if (!evtData || !evtData.points || !evtData.points.length) return; + const hoveredUserId = evtData.points[0].customdata; + if (!hoveredUserId) return; + _highlightUser(plotEl, traces, nClusterTraces, hoveredUserId, trajectoryAnnotations); + }); + + plotEl.on('plotly_unhover', function () { + _resetHighlight(plotEl, traces, nClusterTraces, trajectoryAnnotations); + }); + }); + + // Métadonnées + if (metaEl) { + const uniqueStudents = new Set(points.map(p => p.user_id)).size; + const correctCount = points.filter(p => p.correct).length; + metaEl.style.display = 'block'; + metaEl.innerHTML = + `${data.n_points} tentatives` + + `${nClusters} clusters` + + `${uniqueStudents} étudiants` + + `${correctCount} réussies (★)`; + } +} + +/** + * Met en surbrillance la trajectoire d'un utilisateur. + */ +function _highlightUser(container, traces, nClusterTraces, userId, annotations) { + const DIM = 0.08; + const BRIGHT = 1.0; + + for (let i = 0; i < nClusterTraces; i++) { + const trace = traces[i]; + if (!trace.customdata) continue; + const opacities = trace.customdata.map(uid => uid === userId ? BRIGHT : DIM); + const sizes = []; + if (trace.marker && trace.marker.symbol) { + for (let j = 0; j < trace.customdata.length; j++) { + const isHovered = trace.customdata[j] === userId; + const isStar = trace.marker.symbol[j] === 'star'; + sizes.push(isHovered ? (isStar ? 18 : 11) : (isStar ? 14 : 8)); + } + } + const update = { 'marker.opacity': opacities }; + if (sizes.length) update['marker.size'] = sizes; + Plotly.restyle(container, update, [i]); + } + + for (let i = nClusterTraces; i < traces.length; i++) { + const trace = traces[i]; + const isHl = trace._userId === userId; + Plotly.restyle(container, { + 'line.color': isHl ? trace._colTemplate.replace('{a}', '0.9') : trace._colTemplate.replace('{a}', String(DIM)), + 'line.width': isHl ? 3 : 0.5, + 'line.dash': isHl ? 'solid' : 'dot', + }, [i]); + } + + if (annotations.length > 0) { + const updated = annotations.map(ann => ({ + ...ann, + arrowcolor: ann._colTemplate.replace('{a}', ann._userId === userId ? '0.9' : String(DIM)), + arrowwidth: ann._userId === userId ? 2.5 : 0.8, + opacity: ann._userId === userId ? 1.0 : DIM, + })); + Plotly.relayout(container, { annotations: updated.slice(0, 300) }); + } +} + +/** + * Réinitialise toutes les opacités. + */ +function _resetHighlight(container, traces, nClusterTraces, annotations) { + const DIM = 0.15; + + for (let i = 0; i < nClusterTraces; i++) { + const trace = traces[i]; + if (!trace.customdata) continue; + const opacities = trace.customdata.map(() => DIM); + const sizes = []; + if (trace.marker && trace.marker.symbol) { + for (let j = 0; j < trace.customdata.length; j++) { + sizes.push(trace.marker.symbol[j] === 'star' ? 14 : 8); + } + } + const update = { 'marker.opacity': opacities }; + if (sizes.length) update['marker.size'] = sizes; + Plotly.restyle(container, update, [i]); + } + + for (let i = nClusterTraces; i < traces.length; i++) { + const trace = traces[i]; + Plotly.restyle(container, { + 'line.color': trace._colTemplate.replace('{a}', String(DIM)), + 'line.width': 1, + 'line.dash': 'dot', + }, [i]); + } + + if (annotations.length > 0) { + const reset = annotations.map(ann => ({ + ...ann, + arrowcolor: ann._colTemplate.replace('{a}', String(DIM)), + arrowwidth: 1.5, + opacity: DIM, + })); + Plotly.relayout(container, { annotations: reset.slice(0, 300) }); + } +} + diff --git a/public/js/modules/iaViz.js b/public/js/modules/iaViz.js new file mode 100644 index 00000000..16f2ce7d --- /dev/null +++ b/public/js/modules/iaViz.js @@ -0,0 +1,663 @@ +/** + * iaViz.js — Module de visualisation IA (Macro / Micro) avec Plotly.js + * + * Niveau 1 (Macro) : t-SNE global, centroïdes par TD, clic → zoom Micro + * Niveau 2 (Micro) : K-Means + t-SNE par exercice, trajectoires par étudiant + */ +const IaViz = (function () { + 'use strict'; + + const PLOTLY_CDN = 'https://cdn.plot.ly/plotly-2.32.0.min.js'; + const COLORS_10 = [ + '#3498db', '#e74c3c', '#2ecc71', '#f39c12', '#9b59b6', + '#1abc9c', '#e67e22', '#34495e', '#d35400', '#7f8c8d' + ]; + + let _baseUrl = ''; + let _currentMacroData = null; // Données macro en cache + let _currentMicroData = null; // Données micro en cache + + // ── Initialisation ────────────────────────────────────────────────────── + function init(baseUrl) { + _baseUrl = baseUrl || ''; + _loadPlotly().then(() => { + console.log('[IaViz] Plotly.js chargé.'); + }); + } + + // ── Chargement dynamique de Plotly ────────────────────────────────────── + function _loadPlotly() { + return new Promise((resolve, reject) => { + if (window.Plotly) { resolve(); return; } + const s = document.createElement('script'); + s.src = PLOTLY_CDN; + s.onload = resolve; + s.onerror = () => reject(new Error('Impossible de charger Plotly.js')); + document.head.appendChild(s); + }); + } + + // ══════════════════════════════════════════════════════════════════════════ + // NIVEAU 1 — VUE MACRO + // ══════════════════════════════════════════════════════════════════════════ + + /** + * Lance l'analyse Macro (POST /api/ia/macro). + * @param {Object} opts { resource_id?, perplexity? } + */ + function loadMacro(opts) { + opts = opts || {}; + const perplexity = parseInt(opts.perplexity) || 30; + const resourceId = opts.resource_id || null; + + _showLoading('macroLoading'); + _hideEl('macroError'); + _hideEl('macroResult'); + + const body = { perplexity: perplexity }; + if (resourceId) body.resource_id = parseInt(resourceId); + + fetch(_baseUrl + '/api/ia/macro', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + .then(r => _parseJsonResponse(r)) + .then(data => { + _hideLoading('macroLoading'); + if (data.success) { + _currentMacroData = data; + _renderMacro(data); + _showEl('macroResult'); + } else { + _showError('macroError', data.message || data.error || 'Erreur inconnue'); + } + }) + .catch(err => { + _hideLoading('macroLoading'); + _showError('macroError', 'Erreur réseau : ' + err.message); + }); + } + + /** + * Rendu Plotly de la vue Macro (centroïdes + nuage de fond). + */ + function _renderMacro(data) { + const container = document.getElementById('macroPlot'); + if (!container) return; + + const traces = []; + + // --- Nuage de fond (tous les points, gris transparent) --- + if (data.all_points && data.all_points.length > 0) { + // Regrouper les all_points par exercise_name pour la couleur + const byExo = {}; + data.all_points.forEach(p => { + if (!byExo[p.exercise_name]) byExo[p.exercise_name] = { x: [], y: [], ids: [] }; + byExo[p.exercise_name].x.push(p.x); + byExo[p.exercise_name].y.push(p.y); + byExo[p.exercise_name].ids.push(p.exercice_id); + }); + + let colorIdx = 0; + Object.keys(byExo).forEach(exName => { + const grp = byExo[exName]; + const col = COLORS_10[colorIdx % COLORS_10.length]; + traces.push({ + x: grp.x, + y: grp.y, + mode: 'markers', + type: 'scatter', + name: exName, + marker: { + size: 5, + color: col, + opacity: 0.25, + }, + hoverinfo: 'text', + text: grp.x.map(() => exName), + showlegend: false, + customdata: grp.ids, + }); + colorIdx++; + }); + } + + // --- Centroïdes (gros points cliquables avec labels) --- + if (data.centroids && data.centroids.length > 0) { + const cx = data.centroids.map(c => c.x); + const cy = data.centroids.map(c => c.y); + const labels = data.centroids.map(c => c.exercise_name); + const sizes = data.centroids.map(c => Math.max(14, Math.min(40, 8 + Math.sqrt(c.n_attempts) * 3))); + const colors = data.centroids.map((_, i) => COLORS_10[i % COLORS_10.length]); + const ids = data.centroids.map(c => c.exercice_id); + const hovers = data.centroids.map(c => + `${_esc(c.exercise_name)}
${c.n_attempts} tentatives
Cliquer pour détailler` + ); + + traces.push({ + x: cx, + y: cy, + mode: 'markers+text', + type: 'scatter', + name: 'Centroïdes TDs', + marker: { + size: sizes, + color: colors, + line: { width: 2, color: '#fff' }, + opacity: 0.9, + }, + text: labels, + textposition: 'top center', + textfont: { size: 11, color: '#2c3e50', family: 'sans-serif' }, + hoverinfo: 'text', + hovertext: hovers, + customdata: ids, + showlegend: true, + }); + } + + const layout = { + title: { + text: 'Vue Macro — Cartographie globale des TDs', + font: { size: 16, color: '#2c3e50' }, + }, + xaxis: { title: 't-SNE dim. 1', zeroline: false, showgrid: true, gridcolor: '#ecf0f1' }, + yaxis: { title: 't-SNE dim. 2', zeroline: false, showgrid: true, gridcolor: '#ecf0f1' }, + hovermode: 'closest', + plot_bgcolor: '#fafbfc', + paper_bgcolor: '#fff', + margin: { t: 60, b: 50, l: 60, r: 30 }, + legend: { orientation: 'h', y: -0.15 }, + }; + + Plotly.newPlot(container, traces, layout, { responsive: true }).then(() => { + // Clic sur un centroïde → vue micro + container.on('plotly_click', function (evtData) { + if (!evtData || !evtData.points || evtData.points.length === 0) return; + const pt = evtData.points[0]; + const exerciseId = pt.customdata; + if (exerciseId && typeof exerciseId === 'number' && exerciseId > 0) { + // Trouver le nom de l'exercice + const centroid = data.centroids.find(c => c.exercice_id === exerciseId); + _onMacroClick(exerciseId, centroid ? centroid.exercise_name : ''); + } + }); + }); + + // Métadonnées + const metaEl = document.getElementById('macroMeta'); + if (metaEl) { + metaEl.innerHTML = + `${data.n_points} tentatives analysées` + + `${data.n_exercises} exercices (TDs)`; + } + } + + /** + * Callback quand on clique sur un centroïde macro → passage en vue micro. + */ + function _onMacroClick(exerciseId, exerciseName) { + // Basculer vers l'onglet Micro + document.querySelectorAll('.ia-tab-content').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.ia-tab-btn').forEach(b => b.classList.remove('active')); + + const microTab = document.getElementById('tab-micro'); + if (microTab) microTab.classList.add('active'); + const btns = document.querySelectorAll('.ia-tab-btn'); + if (btns.length >= 3) btns[2].classList.add('active'); + + // Pré-remplir le sélecteur d'exercice + const sel = document.getElementById('microExercise'); + if (sel) { + sel.value = exerciseId; + // Si l'option n'existe pas, la créer + if (sel.value != exerciseId) { + const opt = document.createElement('option'); + opt.value = exerciseId; + opt.textContent = exerciseName || 'Exercice #' + exerciseId; + sel.appendChild(opt); + sel.value = exerciseId; + } + } + + // Pré-remplir l'info + const infoEl = document.getElementById('microSelectedExo'); + if (infoEl) { + infoEl.textContent = exerciseName || 'Exercice #' + exerciseId; + } + + // Lancer automatiquement la vue micro + loadMicro({ + exercise_id: exerciseId, + n_clusters: parseInt(document.getElementById('microK')?.value) || 8, + perplexity: parseInt(document.getElementById('microPerplexity')?.value) || 30, + }); + } + + // ══════════════════════════════════════════════════════════════════════════ + // NIVEAU 2 — VUE MICRO (+ TRAJECTOIRES) + // ══════════════════════════════════════════════════════════════════════════ + + /** + * Lance l'analyse Micro (POST /api/ia/micro). + * @param {Object} opts { exercise_id, n_clusters?, perplexity? } + */ + function loadMicro(opts) { + if (!opts || !opts.exercise_id) { + _showError('microError', 'Veuillez sélectionner un exercice.'); + return; + } + + _showLoading('microLoading'); + _hideEl('microError'); + _hideEl('microResult'); + + fetch(_baseUrl + '/api/ia/micro', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + exercise_id: parseInt(opts.exercise_id), + n_clusters: parseInt(opts.n_clusters) || 8, + perplexity: parseInt(opts.perplexity) || 30, + }), + }) + .then(r => _parseJsonResponse(r)) + .then(data => { + _hideLoading('microLoading'); + if (data.success) { + _currentMicroData = data; + _renderMicro(data); + _showEl('microResult'); + } else { + _showError('microError', data.message || data.error || 'Erreur inconnue'); + } + }) + .catch(err => { + _hideLoading('microLoading'); + _showError('microError', 'Erreur réseau : ' + err.message); + }); + } + + /** + * Rendu Plotly de la vue Micro (clusters + trajectoires + hover focus). + */ + function _renderMicro(data) { + const container = document.getElementById('microPlot'); + if (!container) return; + + const points = data.points || []; + if (points.length === 0) return; + + const nClusters = data.n_clusters || 8; + const traces = []; + + // ── Palette de couleurs par cluster ── + const PALETTE = [ + '#3498db', '#e74c3c', '#2ecc71', '#f39c12', '#9b59b6', + '#1abc9c', '#e67e22', '#34495e', '#d35400', '#16a085' + ]; + + // ══════════════════════════════════════════════════════════════ + // LOGIQUE 1 — Statistiques par cluster + nommage sémantique + // ══════════════════════════════════════════════════════════════ + const clusterStats = {}; + points.forEach(point => { + const cl = point.cluster; + if (!clusterStats[cl]) { + clusterStats[cl] = { total: 0, corrects: 0 }; + } + clusterStats[cl].total++; + if (point.correct == 1) clusterStats[cl].corrects++; + }); + + const getClusterName = (clusterId) => { + const stats = clusterStats[clusterId]; + if (!stats) return `Groupe ${clusterId}`; + const isSuccess = (stats.corrects / stats.total) > 0.5; + return isSuccess + ? `✨ Solutions validées (${stats.total} pts)` + : `Erreurs / Stratégie ${clusterId} (${stats.total} pts)`; + }; + + // ══════════════════════════════════════════════════════════════ + // LOGIQUE 2 — Traces avec GROS points et lignes épaisses + // ══════════════════════════════════════════════════════════════ + for (let c = 0; c < nClusters; c++) { + const clusterPts = points.filter(p => p.cluster === c); + if (clusterPts.length === 0) continue; + + const col = PALETTE[c % PALETTE.length]; + + traces.push({ + x: clusterPts.map(p => p.x), + y: clusterPts.map(p => p.y), + mode: 'lines+markers', + type: 'scatter', + name: getClusterName(c), + line: { + width: 2.5, + color: 'rgba(150, 150, 150, 0.4)', + }, + marker: { + size: 10, + color: col, + opacity: 0.9, + symbol: clusterPts.map(p => p.correct ? 'star' : 'circle'), + line: { width: 1, color: '#ffffff' }, + }, + hoverinfo: 'text', + text: clusterPts.map(p => { + const dateStr = p.date && p.date !== '' && p.date !== 'None' + ? _formatDate(p.date) + : '—'; + return `👤 Étudiant : ${_esc(p.user_id)}
` + + `🎯 Cluster : ${p.cluster}
` + + `${p.correct ? '✅' : '❌'} Correct : ${p.correct ? 'Oui' : 'Non'}
` + + `📝 Tentative : #${p.attempt_id}
` + + `📅 Date : ${dateStr}
` + + `📍 Position : (${p.x.toFixed(2)}, ${p.y.toFixed(2)})`; + }), + customdata: clusterPts.map(p => p.user_id), + hoverlabel: { + bgcolor: '#2c3e50', + bordercolor: '#ecf0f1', + font: { color: '#fff', size: 13, family: 'sans-serif' }, + }, + }); + } + + const nClusterTraces = traces.length; + + // --- Trajectoires par étudiant (lignes dédiées avec flèches) --- + const showTrajectories = document.getElementById('microShowTrajectories')?.checked !== false; + const trajectoryAnnotations = []; + + if (showTrajectories) { + const byUser = {}; + points.forEach(p => { + if (!byUser[p.user_id]) byUser[p.user_id] = []; + byUser[p.user_id].push(p); + }); + + const trajColors = [ + 'rgba(52,73,94,{a})', 'rgba(142,68,173,{a})', 'rgba(41,128,185,{a})', + 'rgba(39,174,96,{a})', 'rgba(243,156,18,{a})', 'rgba(192,57,43,{a})', + 'rgba(22,160,133,{a})', 'rgba(127,140,141,{a})', + ]; + + let colIdx = 0; + + Object.keys(byUser).forEach(userId => { + let userPts = byUser[userId]; + if (userPts.length < 2) return; + + userPts.sort((a, b) => { + if (a.date && b.date && a.date !== b.date) return a.date.localeCompare(b.date); + return (a.attempt_id || 0) - (b.attempt_id || 0); + }); + + const colTemplate = trajColors[colIdx % trajColors.length]; + const dimCol = colTemplate.replace('{a}', '0.45'); + colIdx++; + + traces.push({ + x: userPts.map(p => p.x), + y: userPts.map(p => p.y), + mode: 'lines', + type: 'scatter', + name: `Traj. ${userId}`, + line: { + color: dimCol, + width: 2.5, + dash: 'dot', + }, + hoverinfo: 'skip', + showlegend: false, + customdata: userPts.map(() => userId), + _userId: userId, + _colTemplate: colTemplate, + }); + + for (let i = 0; i < userPts.length - 1; i++) { + const fromPt = userPts[i]; + const toPt = userPts[i + 1]; + const dx = toPt.x - fromPt.x; + const dy = toPt.y - fromPt.y; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < 0.3) continue; + + trajectoryAnnotations.push({ + x: toPt.x, + y: toPt.y, + ax: fromPt.x, + ay: fromPt.y, + xref: 'x', + yref: 'y', + axref: 'x', + ayref: 'y', + showarrow: true, + arrowhead: 3, + arrowsize: 1.4, + arrowwidth: 2, + arrowcolor: colTemplate.replace('{a}', '0.35'), + standoff: 5, + startstandoff: 5, + opacity: 0.5, + _userId: userId, + _colTemplate: colTemplate, + }); + } + }); + } + + // ══════════════════════════════════════════════════════════════ + // LOGIQUE 3 — Layout nettoyé (fond transparent, sans grille) + // ══════════════════════════════════════════════════════════════ + const layout = { + title: "Trajectoires d'apprentissage des étudiants", + hovermode: 'closest', + paper_bgcolor: 'rgba(0,0,0,0)', + plot_bgcolor: 'rgba(0,0,0,0)', + xaxis: { showgrid: false, zeroline: false, showticklabels: false }, + yaxis: { showgrid: false, zeroline: false, showticklabels: false }, + legend: { itemsizing: 'constant', font: { size: 14 } }, + margin: { t: 60, b: 40, l: 30, r: 30 }, + annotations: trajectoryAnnotations.length > 300 + ? trajectoryAnnotations.slice(0, 300) + : trajectoryAnnotations, + }; + + Plotly.newPlot(container, traces, layout, { responsive: true }).then(() => { + container.on('plotly_hover', function (evtData) { + if (!evtData || !evtData.points || evtData.points.length === 0) return; + const pt = evtData.points[0]; + const hoveredUserId = pt.customdata; + if (!hoveredUserId) return; + + _highlightUser(container, traces, nClusterTraces, hoveredUserId, trajectoryAnnotations, layout); + }); + + container.on('plotly_unhover', function () { + _resetHighlight(container, traces, nClusterTraces, trajectoryAnnotations, layout); + }); + }); + + // Métadonnées + const metaEl = document.getElementById('microMeta'); + if (metaEl) { + const uniqueStudents = new Set(points.map(p => p.user_id)).size; + const correctCount = points.filter(p => p.correct).length; + metaEl.innerHTML = + `${data.n_points} tentatives` + + `${nClusters} clusters` + + `${uniqueStudents} étudiants` + + `${correctCount} réussies (★ = réussite)` + + `Exercice : ${_esc(data.exercise_name || '')}`; + } + } + + /** + * Met en surbrillance la trajectoire d'un utilisateur donné. + */ + function _highlightUser(container, traces, nClusterTraces, userId, annotations, layout) { + const FADED = 0.12; + const BRIGHT = 1.0; + + // Points de clusters + for (let i = 0; i < nClusterTraces; i++) { + const trace = traces[i]; + if (!trace.customdata) continue; + const opacities = trace.customdata.map(uid => uid === userId ? BRIGHT : FADED); + const sizes = trace.customdata.map(uid => uid === userId ? 16 : 10); + Plotly.restyle(container, { + 'marker.opacity': opacities, + 'marker.size': sizes, + }, [i]); + } + + // Trajectoires (lignes) + for (let i = nClusterTraces; i < traces.length; i++) { + const trace = traces[i]; + const isHighlighted = trace._userId === userId; + Plotly.restyle(container, { + 'line.color': isHighlighted + ? trace._colTemplate.replace('{a}', '0.95') + : trace._colTemplate.replace('{a}', '0.06'), + 'line.width': isHighlighted ? 4 : 0.5, + 'line.dash': isHighlighted ? 'solid' : 'dot', + }, [i]); + } + + // Annotations (flèches) + if (annotations.length > 0) { + const updated = annotations.map(ann => { + const isH = ann._userId === userId; + return Object.assign({}, ann, { + arrowcolor: ann._colTemplate.replace('{a}', isH ? '0.95' : '0.06'), + arrowwidth: isH ? 3 : 0.5, + opacity: isH ? 1.0 : 0.06, + }); + }); + Plotly.relayout(container, { + annotations: updated.length > 300 ? updated.slice(0, 300) : updated, + }); + } + } + + /** + * Réinitialise toutes les opacités (état par défaut : bien visible). + */ + function _resetHighlight(container, traces, nClusterTraces, annotations, layout) { + // Points de clusters → retour à opacity 0.9, size 10 + for (let i = 0; i < nClusterTraces; i++) { + const trace = traces[i]; + if (!trace.customdata) continue; + const opacities = trace.customdata.map(() => 0.9); + const sizes = trace.customdata.map(() => 10); + Plotly.restyle(container, { + 'marker.opacity': opacities, + 'marker.size': sizes, + }, [i]); + } + + // Trajectoires → retour à width 2.5, opacité 0.45 + for (let i = nClusterTraces; i < traces.length; i++) { + const trace = traces[i]; + Plotly.restyle(container, { + 'line.color': trace._colTemplate.replace('{a}', '0.45'), + 'line.width': 2.5, + 'line.dash': 'dot', + }, [i]); + } + + // Annotations → retour à opacité 0.5 + if (annotations.length > 0) { + const reset = annotations.map(ann => Object.assign({}, ann, { + arrowcolor: ann._colTemplate.replace('{a}', '0.35'), + arrowwidth: 2, + opacity: 0.5, + })); + Plotly.relayout(container, { + annotations: reset.length > 300 ? reset.slice(0, 300) : reset, + }); + } + } + + /** + * Formate une date ISO en format lisible. + */ + function _formatDate(dateStr) { + if (!dateStr || dateStr === 'None' || dateStr === '') return '—'; + try { + const d = new Date(dateStr); + if (isNaN(d.getTime())) return dateStr; + return d.toLocaleDateString('fr-FR', { + day: '2-digit', month: 'short', year: 'numeric', + hour: '2-digit', minute: '2-digit', + }); + } catch (e) { + return dateStr; + } + } + + /** + * Re-render micro avec/sans trajectoires (toggle). + */ + function toggleTrajectories() { + if (_currentMicroData) { + _renderMicro(_currentMicroData); + } + } + + // ══════════════════════════════════════════════════════════════════════════ + // HELPERS + // ══════════════════════════════════════════════════════════════════════════ + + function _parseJsonResponse(r) { + const ct = r.headers.get('content-type') || ''; + if (!ct.includes('application/json')) { + return r.text().then(txt => { + throw new Error('Réponse non-JSON (HTTP ' + r.status + ')'); + }); + } + return r.json(); + } + + function _showLoading(id) { + const el = document.getElementById(id); + if (el) el.classList.add('visible'); + } + function _hideLoading(id) { + const el = document.getElementById(id); + if (el) el.classList.remove('visible'); + } + function _showEl(id) { + const el = document.getElementById(id); + if (el) el.classList.add('visible'); + } + function _hideEl(id) { + const el = document.getElementById(id); + if (el) el.classList.remove('visible'); + } + function _showError(id, msg) { + const el = document.getElementById(id); + if (el) { + el.textContent = '❌ ' + msg; + el.classList.add('visible'); + } + } + function _esc(str) { + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + // ── API publique ──────────────────────────────────────────────────────── + return { + init: init, + loadMacro: loadMacro, + loadMicro: loadMicro, + toggleTrajectories: toggleTrajectories, + }; + +})(); diff --git a/public/js/modules/vizManager.js b/public/js/modules/vizManager.js index b0de3856..fbcd4c07 100644 --- a/public/js/modules/vizManager.js +++ b/public/js/modules/vizManager.js @@ -9,6 +9,7 @@ import { StatsRenderer } from '/public/js/modules/statsRenderer.js'; import { AttemptsRenderer } from '/public/js/modules/attemptsRenderer.js'; +import { renderMacroSection, renderMicroSection } from '/public/js/modules/iaIntegration.js'; export class VizManager { constructor() { @@ -97,6 +98,19 @@ export class VizManager { this._renderTPBarChart(exercisesData, 'viz-chart-tp'); this._renderGlobalPieChart(totalCorrect, totalAttempts - totalCorrect, 'viz-chart-global'); + // ── Section IA Macro (Cartographie globale des TDs) ── + if (this.resourceId) { + const self = this; + await renderMacroSection(container, this.resourceId, (exerciseId, exerciseName) => { + // Callback : clic sur un centroïde → navigation vers la vue Micro du TD + const dataZone = document.querySelector('.viz-data-zone'); + if (dataZone) { + self.renderLevel2TP(dataZone, exerciseId, exerciseName); + dataZone.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }); + } + } catch (err) { console.error('[VizManager] renderLevel1 error:', err); container.innerHTML = '

Erreur lors du chargement des données.

'; @@ -216,6 +230,9 @@ export class VizManager { this._renderTPStudentLines(students, 'viz-tp-lines', container, { id: exerciseId, name: displayName }); this._renderTPStackedBar(students, 'viz-tp-stacked', container, { id: exerciseId, name: displayName }); + // ── Section IA Micro (Trajectoires pour ce TD) ── + await renderMicroSection(container, exerciseId, displayName); + } catch (err) { console.error('[VizManager] renderLevel2TP:', err); container.innerHTML = '

Erreur lors du chargement.

'; diff --git a/scripts/clustering_pipeline.py b/scripts/clustering_pipeline.py new file mode 100644 index 00000000..ece89d1d --- /dev/null +++ b/scripts/clustering_pipeline.py @@ -0,0 +1,411 @@ +#!/usr/bin/env python3 +# -*- coding: UTF-8 -*- +""" +clustering_pipeline.py +Pipeline Data Science : Doc2Vec -> KMeans -> t-SNE -> scatter plot base64 + +Trois modes d'utilisation : + 1) --from-stdin : reçoit les données JSON depuis stdin (envoyé par PHP) + Le champ "mode" dans le JSON détermine le comportement : + - "micro" (défaut) : clustering + t-SNE pour UN exercice, renvoie coordonnées individuelles + - "global" : t-SNE global sur TOUS les exercices, renvoie centroïdes par TD + 2) --exercise_id : se connecte directement à MySQL (usage CLI autonome) + +Renvoie un JSON sur stdout. +""" + +import sys +import os +import json +import argparse +import base64 +import io +import warnings + +warnings.filterwarnings("ignore") + +# ── Chemins ────────────────────────────────────────────────────────────────── +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +PROJECT_ROOT = os.path.dirname(SCRIPT_DIR) + +# S'assurer que le dossier utils/ existe pour les fichiers .cor temporaires +os.makedirs(os.path.join(SCRIPT_DIR, 'utils'), exist_ok=True) + + +# ── Pipeline MICRO (un seul exercice) ──────────────────────────────────────── +def run_pipeline_micro(data, n_clusters=8, perplexity=30, exercise_id=None): + """Pipeline micro : clustering K-Means + t-SNE pour un exercice. + Renvoie les coordonnées individuelles avec cluster, user_id, date.""" + + if len(data) < 5: + return { + 'success': False, + 'error': f"Pas assez de tentatives avec AES ({len(data)} trouvées, minimum 5)." + } + + exercise_name = data[0].get('exercise_name', f'exercise_{exercise_id}') + + # 1) Préparer les données au format attendu par aes2vec + for att in data: + if not att.get('eval_set'): + att['eval_set'] = 'training' + + train_data = [dict(att, eval_set='training') for att in data] + infer_data = [dict(att, eval_set='test') for att in data] + + # 2) Doc2Vec : entraînement + inférence + old_cwd = os.getcwd() + os.chdir(SCRIPT_DIR) + + try: + from aes2vec import learnModel, inferVectors + import numpy as np + + model = learnModel( + train_data, + selectionfield='eval_set', + selectionsets=['training'], + valuefield='aes2', + vsize=100, + cwindow=5, + niter=100 + ) + + vectors = inferVectors( + model, + infer_data, + selectionfield='eval_set', + selectionsets=['test'], + valuefield='aes2' + ) + + vectors = np.array(vectors) + finally: + os.chdir(old_cwd) + + if len(vectors) < 5: + return { + 'success': False, + 'error': f"Pas assez de vecteurs générés ({len(vectors)})." + } + + # 3) KMeans clustering + from sklearn.cluster import KMeans + + actual_n_clusters = min(n_clusters, len(vectors)) + kmeans = KMeans(n_clusters=actual_n_clusters, random_state=42, n_init=10) + labels = kmeans.fit_predict(vectors) + + # 4) t-SNE réduction 2D + from sklearn.manifold import TSNE + from sklearn.preprocessing import normalize + + # Normaliser les vecteurs avant t-SNE pour améliorer la séparation + vectors_normed = normalize(vectors, norm='l2') + + actual_perplexity = min(perplexity, max(5, len(vectors_normed) // 3)) + tsne = TSNE( + n_components=2, + perplexity=actual_perplexity, + random_state=42, + max_iter=1500, + learning_rate='auto', + init='pca', + early_exaggeration=12.0, + metric='cosine', + ) + coords_2d = tsne.fit_transform(vectors_normed) + + # 5) Construire les points individuels (pour Plotly côté JS) + points = [] + for i, att in enumerate(data): + points.append({ + 'x': float(coords_2d[i, 0]), + 'y': float(coords_2d[i, 1]), + 'cluster': int(labels[i]), + 'user_id': str(att.get('user_id', att.get('student_identifier', '?'))), + 'attempt_id': att.get('attempt_id', i), + 'correct': int(att.get('correct', 0)), + 'date': str(att.get('submission_date', att.get('date', ''))), + }) + + # 6) Génération du scatter plot matplotlib → base64 (rétro-compatibilité) + import matplotlib + matplotlib.use('Agg') + import matplotlib.pyplot as plt + import matplotlib.cm as cm + + fig, ax = plt.subplots(figsize=(10, 7)) + colors = cm.get_cmap('tab10', actual_n_clusters) + + for cluster_id in range(actual_n_clusters): + mask = labels == cluster_id + ax.scatter( + coords_2d[mask, 0], + coords_2d[mask, 1], + c=[colors(cluster_id)], + label=f'Cluster {cluster_id}', + alpha=0.7, + s=50, + edgecolors='white', + linewidths=0.5, + ) + + ax.set_title(f'Cartographie des codes — {exercise_name}\n' + f'({len(data)} tentatives, {actual_n_clusters} clusters)', + fontsize=13, fontweight='bold') + ax.set_xlabel('t-SNE dimension 1', fontsize=10) + ax.set_ylabel('t-SNE dimension 2', fontsize=10) + ax.legend(loc='best', fontsize=8, framealpha=0.9) + ax.grid(True, alpha=0.3) + fig.tight_layout() + + buf = io.BytesIO() + fig.savefig(buf, format='png', dpi=120, bbox_inches='tight') + plt.close(fig) + buf.seek(0) + img_base64 = 'data:image/png;base64,' + base64.b64encode(buf.read()).decode('utf-8') + buf.close() + + students = [str(att.get('user_id', att.get('student_identifier', '?'))) for att in data] + correct_list = [int(att.get('correct', 0)) for att in data] + + return { + 'success': True, + 'mode': 'micro', + 'image_base64': img_base64, + 'n_points': len(data), + 'n_clusters': actual_n_clusters, + 'exercise_name': exercise_name, + 'clusters': labels.tolist(), + 'students': students, + 'correct': correct_list, + 'points': points, + } + + +# ── Pipeline GLOBAL (tous les exercices → centroïdes par TD) ───────────────── +def run_pipeline_global(data, perplexity=30): + """Pipeline global : Doc2Vec + t-SNE sur TOUTES les tentatives. + Renvoie les centroïdes par exercise_name pour la vue macro.""" + + if len(data) < 5: + return { + 'success': False, + 'error': f"Pas assez de tentatives avec AES ({len(data)} trouvées, minimum 5)." + } + + # 1) Préparer les données + for att in data: + if not att.get('eval_set'): + att['eval_set'] = 'training' + + train_data = [dict(att, eval_set='training') for att in data] + infer_data = [dict(att, eval_set='test') for att in data] + + # 2) Doc2Vec + old_cwd = os.getcwd() + os.chdir(SCRIPT_DIR) + + try: + from aes2vec import learnModel, inferVectors + import numpy as np + + model = learnModel( + train_data, + selectionfield='eval_set', + selectionsets=['training'], + valuefield='aes2', + vsize=100, + cwindow=5, + niter=100 + ) + + vectors = inferVectors( + model, + infer_data, + selectionfield='eval_set', + selectionsets=['test'], + valuefield='aes2' + ) + + vectors = np.array(vectors) + finally: + os.chdir(old_cwd) + + if len(vectors) < 5: + return { + 'success': False, + 'error': f"Pas assez de vecteurs générés ({len(vectors)})." + } + + # 3) t-SNE global + from sklearn.manifold import TSNE + from sklearn.preprocessing import normalize + import numpy as np + + # Normaliser les vecteurs avant t-SNE pour améliorer la séparation + vectors_normed = normalize(vectors, norm='l2') + + actual_perplexity = min(perplexity, max(5, len(vectors_normed) // 3)) + tsne = TSNE( + n_components=2, + perplexity=actual_perplexity, + random_state=42, + max_iter=1500, + learning_rate='auto', + init='pca', + early_exaggeration=12.0, + metric='cosine', + ) + coords_2d = tsne.fit_transform(vectors_normed) + + # 4) Regrouper par exercise_name et calculer les centroïdes + exercise_points = {} # exercise_name -> list of (x, y, exercice_id) + all_points = [] + + for i, att in enumerate(data): + ex_name = att.get('exercise_name', 'Inconnu') + ex_id = att.get('exercice_id', att.get('exercise_id', 0)) + x = float(coords_2d[i, 0]) + y = float(coords_2d[i, 1]) + + if ex_name not in exercise_points: + exercise_points[ex_name] = {'xs': [], 'ys': [], 'exercice_id': ex_id} + exercise_points[ex_name]['xs'].append(x) + exercise_points[ex_name]['ys'].append(y) + + all_points.append({ + 'x': x, + 'y': y, + 'exercise_name': ex_name, + 'exercice_id': int(ex_id), + }) + + # Centroïdes par TD + centroids = [] + for ex_name, pts in exercise_points.items(): + cx = float(np.mean(pts['xs'])) + cy = float(np.mean(pts['ys'])) + centroids.append({ + 'exercise_name': ex_name, + 'exercice_id': int(pts['exercice_id']), + 'x': cx, + 'y': cy, + 'n_attempts': len(pts['xs']), + }) + + return { + 'success': True, + 'mode': 'global', + 'n_points': len(data), + 'n_exercises': len(centroids), + 'centroids': centroids, + 'all_points': all_points, + } + + +# ── Lecture depuis stdin (mode appelé par PHP) ─────────────────────────────── +def run_from_stdin(): + """Lit le JSON depuis stdin et lance le pipeline approprié.""" + raw = sys.stdin.read() + payload = json.loads(raw) + + mode = payload.get('mode', 'micro') + data = payload['attempts'] + n_clusters = int(payload.get('n_clusters', 8)) + perplexity = int(payload.get('perplexity', 30)) + exercise_id = payload.get('exercise_id') + + if mode == 'global': + return run_pipeline_global(data, perplexity) + else: + return run_pipeline_micro(data, n_clusters, perplexity, exercise_id) + + +# ── Lecture depuis MySQL (mode CLI autonome) ───────────────────────────────── +def run_from_db(exercise_id, n_clusters=8, perplexity=30): + """Se connecte à MySQL, charge les données et lance le pipeline.""" + env = _load_env() + conn = _get_connection(env) + data = _load_attempts(conn, exercise_id) + conn.close() + return run_pipeline_micro(data, n_clusters, perplexity, exercise_id) + + +def _load_env(): + possible_paths = [ + os.path.join(PROJECT_ROOT, 'config', '.env'), + os.path.join(PROJECT_ROOT, '..', 'config', '.env'), + os.path.join(PROJECT_ROOT, '.env'), + ] + config = {} + for env_path in possible_paths: + if os.path.exists(env_path): + with open(env_path, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + if not line or line.startswith('#') or line.startswith(';'): + continue + if '=' in line: + key, val = line.split('=', 1) + config[key.strip()] = val.strip().strip('"').strip("'") + return config + return {'DB_HOST': '127.0.0.1', 'DB_NAME': 'studtraj', 'DB_USER': 'root', 'DB_PASS': ''} + + +def _get_connection(env): + import mysql.connector + host = env.get('DB_HOST', '127.0.0.1') + if host == 'localhost': + host = '127.0.0.1' + return mysql.connector.connect( + host=host, + port=int(env.get('DB_PORT', 3306)), + user=env.get('DB_USER', 'root'), + password=env.get('DB_PASS', ''), + database=env.get('DB_NAME', 'studtraj'), + charset='utf8mb4', + ) + + +def _load_attempts(conn, exercise_id): + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT a.attempt_id, a.aes2, a.eval_set, a.correct, + a.student_id, a.exercice_id, + e.exercice_name AS exercise_name, + s.student_identifier AS user_id + FROM attempts a + JOIN exercices e ON a.exercice_id = e.exercice_id + JOIN students s ON a.student_id = s.student_id + WHERE a.exercice_id = %s AND a.aes2 IS NOT NULL AND a.aes2 != '' + ORDER BY a.attempt_id + """, (exercise_id,)) + rows = cursor.fetchall() + cursor.close() + return rows + + +# ── Point d'entrée CLI ────────────────────────────────────────────────────── +if __name__ == '__main__': + parser = argparse.ArgumentParser(description='Pipeline clustering aes2vec') + parser.add_argument('--from-stdin', action='store_true', + help='Lire les données JSON depuis stdin (mode PHP)') + parser.add_argument('--exercise_id', type=int, default=0, + help='ID de l\'exercice (mode CLI direct)') + parser.add_argument('--n_clusters', type=int, default=8) + parser.add_argument('--perplexity', type=int, default=30) + args = parser.parse_args() + + try: + if args.from_stdin: + result = run_from_stdin() + elif args.exercise_id > 0: + result = run_from_db(args.exercise_id, args.n_clusters, args.perplexity) + else: + result = {'success': False, 'error': 'Spécifiez --from-stdin ou --exercise_id '} + except Exception as e: + result = {'success': False, 'error': str(e)} + + print(json.dumps(result, ensure_ascii=False)) diff --git a/scripts/requirements.txt b/scripts/requirements.txt new file mode 100644 index 00000000..0045b23d --- /dev/null +++ b/scripts/requirements.txt @@ -0,0 +1,6 @@ +gensim>=4.0.0 +smart_open>=5.0.0 +scikit-learn>=1.0.0 +numpy>=1.21.0 +matplotlib>=3.5.0 + diff --git a/scripts/script.py b/scripts/script.py deleted file mode 100644 index e48ad083..00000000 --- a/scripts/script.py +++ /dev/null @@ -1,17 +0,0 @@ -from manage import jsonAttempts2data, jsonExercises2data -NC1014 = jsonAttempts2data('NewCaledonia_1014.json') - - -NCExercises = jsonExercises2data('NewCaledonia_exercises.json') - -from code2aes import Code2Aes - -aes = Code2Aes(NC1014[0],NCExercises) - -from aes2vec import learnModel, inferVectors - -model = learnModel(NC1014) - -results = inferVectors(model, NC1014) -for r in results: - print(r) \ No newline at end of file diff --git a/scripts/utils/input.txt b/scripts/utils/input.txt deleted file mode 100644 index 6d766e70..00000000 --- a/scripts/utils/input.txt +++ /dev/null @@ -1,100 +0,0 @@ -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 \ No newline at end of file