From ed16ff3f167a15920c98230e59c37cc439a327ca Mon Sep 17 00:00:00 2001 From: PibouleauJB Date: Mon, 9 Mar 2026 11:43:24 +0100 Subject: [PATCH 01/29] Tes #1 --- App/Controller/IaController.php | 60 +++++ App/View/resources/details.php | 1 + App/View/resources/list.php | 2 + App/View/user/dashboard.php | 3 +- App/View/user/ia.php | 408 ++++++++++++++++++++++++++++++++ App/routes.php | 3 + 6 files changed, 476 insertions(+), 1 deletion(-) create mode 100644 App/Controller/IaController.php create mode 100644 App/View/user/ia.php diff --git a/App/Controller/IaController.php b/App/Controller/IaController.php new file mode 100644 index 00000000..e1d33ecb --- /dev/null +++ b/App/Controller/IaController.php @@ -0,0 +1,60 @@ +authService = new AuthenticationService(new SessionService()); + } + + /** + * Show the IA dashboard page + * + * @return void + */ + public function index(): void + { + $this->authService->requireAuth('/auth/login'); + + $pdo = DatabaseConnection::getInstance()->getConnection(); + + // Statistiques 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 GROUP BY eval_set ORDER BY eval_set" + )->fetchAll(\PDO::FETCH_ASSOC); + + // Ressources disponibles (pour le sélecteur) + $resources = $pdo->query( + "SELECT ressource_id, ressource_name FROM ressources ORDER BY ressource_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, + ]); + } +} + diff --git a/App/View/resources/details.php b/App/View/resources/details.php index c58928a1..d0878f25 100644 --- a/App/View/resources/details.php +++ b/App/View/resources/details.php @@ -101,6 +101,7 @@
diff --git a/App/View/user/dashboard.php b/App/View/user/dashboard.php index 1ef16782..716444b8 100644 --- a/App/View/user/dashboard.php +++ b/App/View/user/dashboard.php @@ -1,4 +1,4 @@ -
+ + + + +
+

Analyse par IA

+

+ Visualisez les trajectoires d'apprentissage des étudiants grâce au modèle + aes2vec (Doc2Vec), entraîné sur les tentatives importées. +

+ + +
+
+

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.

+ +
+ + +
+

Analyse d'un étudiant

+

+ Sélectionnez une ressource et un étudiant pour visualiser ses performances + et sa position dans l'espace d'embeddings. +

+ +
+
+ + +
+
+ + +
+ +
+ +
+ + + + + + + + + + +
ExerciceTentativesRéussiesTaux de réussite
+
+
+
+ + + + + + + + diff --git a/App/routes.php b/App/routes.php index 3073af8c..a5937ae5 100644 --- a/App/routes.php +++ b/App/routes.php @@ -33,6 +33,9 @@ // Dashboard routes (protected) $router->get('/dashboard', App\Controller\DashboardController::class, 'index'); +// IA route +$router->get('/ia', App\Controller\IaController::class, 'index'); + // Exercise routes $router->get('/exercises', App\Controller\ExercisesController::class, 'index'); $router->get('/exercises/{id}', App\Controller\ExercisesController::class, 'show'); From f2b7a2ff4095371547ea4ee91e52f543af3efb2c Mon Sep 17 00:00:00 2001 From: PibouleauJB Date: Mon, 9 Mar 2026 13:41:09 +0100 Subject: [PATCH 02/29] =?UTF-8?q?fr:=20feat:=20impl=C3=A9mentation=20du=20?= =?UTF-8?q?pipeline=20de=20clustering=20avec=20Doc2Vec,=20KMeans=20et=20vi?= =?UTF-8?q?sualisation=20t-SNE=20en:=20feat:=20implement=20clustering=20pi?= =?UTF-8?q?peline=20with=20Doc2Vec,=20KMeans,=20and=20t-SNE=20visualizatio?= =?UTF-8?q?n=20es:=20feat:=20implementar=20pipeline=20de=20clustering=20co?= =?UTF-8?q?n=20Doc2Vec,=20KMeans=20y=20visualizaci=C3=B3n=20t-SNE=20de:=20?= =?UTF-8?q?feat:=20Implementierung=20der=20Clustering-Pipeline=20mit=20Doc?= =?UTF-8?q?2Vec,=20KMeans=20und=20t-SNE-Visualisierung=20it:=20feat:=20imp?= =?UTF-8?q?lementazione=20della=20pipeline=20di=20clustering=20con=20Doc2V?= =?UTF-8?q?ec,=20KMeans=20e=20visualizzazione=20t-SNE=20zh:=20feat:=20?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E5=8C=85=E5=90=AB=20Doc2Vec=E3=80=81KMeans?= =?UTF-8?q?=20=E5=92=8C=20t-SNE=20=E5=8F=AF=E8=A7=86=E5=8C=96=E7=9A=84?= =?UTF-8?q?=E8=81=9A=E7=B1=BB=E6=B5=81=E6=B0=B4=E7=BA=BF=20ja:=20feat:=20D?= =?UTF-8?q?oc2Vec=E3=80=81KMeans=E3=80=81t-SNE=E5=8F=AF=E8=A6=96=E5=8C=96?= =?UTF-8?q?=E3=81=AB=E3=82=88=E3=82=8B=E3=82=AF=E3=83=A9=E3=82=B9=E3=82=BF?= =?UTF-8?q?=E3=83=AA=E3=83=B3=E3=82=B0=E3=83=91=E3=82=A4=E3=83=97=E3=83=A9?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=81=AE=E5=AE=9F=E8=A3=85=20ru:=20feat:=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D0=B2=D0=B5=D0=B9=D0=B5=D1=80=D0=B0=20=D0=BA?= =?UTF-8?q?=D0=BB=D0=B0=D1=81=D1=82=D0=B5=D1=80=D0=B8=D0=B7=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20=D1=81=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=D0=BC=20Doc2Vec,=20K?= =?UTF-8?q?Means=20=D0=B8=20=D0=B2=D0=B8=D0=B7=D1=83=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20t-SNE=20ar:=20feat:=20=D8=AA?= =?UTF-8?q?=D9=86=D9=81=D9=8A=D8=B0=20=D8=AE=D8=B7=20=D8=A3=D9=86=D8=A7?= =?UTF-8?q?=D8=A8=D9=8A=D8=A8=20=D8=A7=D9=84=D8=AA=D8=AC=D9=85=D9=8A=D8=B9?= =?UTF-8?q?=20=D8=A8=D8=A7=D8=B3=D8=AA=D8=AE=D8=AF=D8=A7=D9=85=20Doc2Vec?= =?UTF-8?q?=20=D9=88=20KMeans=20=D9=88=D8=AA=D8=B5=D9=88=D8=B1=20t-SNE=20p?= =?UTF-8?q?t:=20feat:=20implementar=20pipeline=20de=20clustering=20com=20D?= =?UTF-8?q?oc2Vec,=20KMeans=20e=20visualiza=C3=A7=C3=A3o=20t-SNE=20hi:=20f?= =?UTF-8?q?eat:=20Doc2Vec,=20KMeans=20=E0=A4=94=E0=A4=B0=20t-SNE=20?= =?UTF-8?q?=E0=A4=B5=E0=A4=BF=E0=A4=9C=E0=A4=BC=E0=A5=81=E0=A4=85=E0=A4=B2?= =?UTF-8?q?=E0=A4=BE=E0=A4=87=E0=A4=9C=E0=A4=BCation=20=E0=A4=95=E0=A5=87?= =?UTF-8?q?=20=E0=A4=B8=E0=A4=BE=E0=A4=A5=20=E0=A4=95=E0=A5=8D=E0=A4=B2?= =?UTF-8?q?=E0=A4=B8=E0=A5=8D=E0=A4=9F=E0=A4=B0=E0=A4=BF=E0=A4=82=E0=A4=97?= =?UTF-8?q?=20=E0=A4=AA=E0=A4=BE=E0=A4=87=E0=A4=AA=E0=A4=B2=E0=A4=BE?= =?UTF-8?q?=E0=A4=87=E0=A4=A8=20=E0=A4=B2=E0=A4=BE=E0=A4=97=E0=A5=82=20?= =?UTF-8?q?=E0=A4=95=E0=A5=80=20=E0=A4=97=E0=A4=88=20ko:=20feat:=20Doc2Vec?= =?UTF-8?q?,=20KMeans=20=EB=B0=8F=20t-SNE=20=EC=8B=9C=EA=B0=81=ED=99=94?= =?UTF-8?q?=EB=A5=BC=20=ED=8F=AC=ED=95=A8=ED=95=9C=20=ED=81=B4=EB=9F=AC?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=EB=A7=81=20=ED=8C=8C=EC=9D=B4=ED=94=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=EA=B5=AC=ED=98=84=20nl:=20feat:=20impleme?= =?UTF-8?q?ntatie=20van=20clustering-pipeline=20met=20Doc2Vec,=20KMeans=20?= =?UTF-8?q?en=20t-SNE-visualisatie=20tr:=20feat:=20Doc2Vec,=20KMeans=20ve?= =?UTF-8?q?=20t-SNE=20g=C3=B6rselle=C5=9Ftirmesi=20ile=20k=C3=BCmeleme=20a?= =?UTF-8?q?rd=C4=B1=C5=9F=C4=B1k=20d=C3=BCzeninin=20uygulanmas=C4=B1=20pl:?= =?UTF-8?q?=20feat:=20implementacja=20potoku=20klasteryzacji=20z=20Doc2Vec?= =?UTF-8?q?,=20KMeans=20i=20wizualizacj=C4=85=20t-SNE=20tlh:=20feat:=20Doc?= =?UTF-8?q?2Vec=20KMeans=20je=20t-SNE=20ghomlu'meH=20pat=20sim:=20feat:=20?= =?UTF-8?q?Sul=20sul!=20Clustering=20Doc2Vec=20KMeans=20t-SNE=20visualizat?= =?UTF-8?q?ion=20wabadebadoo=20min:=20feat:=20Clustering=20Doc2Vec=20KMean?= =?UTF-8?q?s=20t-SNE=20visualization!=20Ba-na-na=20nav:=20feat:=20Rel=20Do?= =?UTF-8?q?c2Vec=20s=C3=AC=20KMeans=20s=C3=AC=20t-SNE=20visualization=20fp?= =?UTF-8?q?i=20f=C3=ACtseng=20doth:=20feat:=20Clustering=20pipeline=20Doc2?= =?UTF-8?q?Vec=20KMeans=20ma=20t-SNE=20visualization=20sch:=20feat:=20Scht?= =?UTF-8?q?roumpfer=20le=20pipeline=20de=20clustering=20avec=20Doc2Vec,=20?= =?UTF-8?q?KMeans=20et=20la=20schtroumpfisation=20t-SNE=20tp:=20feat:=20pa?= =?UTF-8?q?li=20e=20kulupu=20kepeken=20Doc2Vec=20en=20KMeans=20en=20sitele?= =?UTF-8?q?n=20t-SNE=20eo:=20feat:=20efektivigi=20klastan=20dukton=20per?= =?UTF-8?q?=20Doc2Vec,=20KMeans=20kaj=20t-SNE-bildigo=20sil:=20feat:=20i-u?= =?UTF-8?q?-ee-o=20Doc2Vec=20KMeans=20t-SNE=20visualization=20lch:=20feat:?= =?UTF-8?q?=20limpl=C3=A9mentation-lem=20du=20lipeline-p=C3=A9m=20de=20lus?= =?UTF-8?q?tering-cl=C3=A9m=20avec=20Doc2Vec,=20KMeans=20et=20lisualisatio?= =?UTF-8?q?n-v=C3=A9m=20t-SNE=20pig:=20feat:=20implement-way=20ustering-cl?= =?UTF-8?q?ay=20ipeline-pay=20ith-way=20Doc2Vec,=20KMeans,=20and-way=20t-S?= =?UTF-8?q?NE=20isualization-vay=20sin:=20feat:=20Clustering=20pipeline=20?= =?UTF-8?q?Doc2Vec=20ar=20KMeans=20ar=20t-SNE=20visualization=20hut:=20fea?= =?UTF-8?q?t:=20Clustering=20pipeline=20Doc2Vec=20KMeans=20t-SNE=20visuali?= =?UTF-8?q?zation=20moova=20sol:=20feat:=20do-re-mi=20Doc2Vec=20KMeans=20t?= =?UTF-8?q?-SNE=20visualization=20pir:=20feat:=20clustering=20Doc2Vec=20KM?= =?UTF-8?q?eans=20t-SNE=20visualization=20hi-hi=20kab:=20feat:=20asbeddi?= =?UTF-8?q?=20n=20tmahilt=20n=20clustering=20s=20Doc2Vec,=20KMeans=20d=20u?= =?UTF-8?q?beqqi=20n=20t-SNE=20mrs:=20feat:=20Oh=20l'ami,=20j'ai=20envoy?= =?UTF-8?q?=C3=A9=20le=20pipeline=20de=20clustering=20avec=20Doc2Vec=20et?= =?UTF-8?q?=20KMeans,=20et=20je=20t'ai=20mis=20la=20visu=20t-SNE=20pour=20?= =?UTF-8?q?que=20ce=20soit=20tarpin=20propre=20bre:=20feat:=20lakaat=20e?= =?UTF-8?q?=20pleustr=20ar=20san-stur=20klastara=C3=B1=20gant=20Doc2Vec,?= =?UTF-8?q?=20KMeans=20ha=20gweledikaat=20t-SNE=20cor:=20feat:=20implement?= =?UTF-8?q?azione=20di=20u=20pipeline=20di=20clustering=20c=C3=B9=20Doc2Ve?= =?UTF-8?q?c,=20KMeans=20=C3=A8=20visualisazione=20t-SNE=20arr:=20feat:=20?= =?UTF-8?q?Avast!=20I=20be=20buildin'=20the=20clustering=20pipeline=20with?= =?UTF-8?q?=20Doc2Vec,=20KMeans,=20and=20t-SNE=20maps=20to=20find=20the=20?= =?UTF-8?q?hidden=20treasure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- App/Controller/IaController.php | 109 +++++- App/View/user/ia.php | 648 +++++++++++++++++--------------- App/routes.php | 3 + scripts/clustering_pipeline.py | 271 +++++++++++++ 4 files changed, 716 insertions(+), 315 deletions(-) create mode 100644 scripts/clustering_pipeline.py diff --git a/App/Controller/IaController.php b/App/Controller/IaController.php index e1d33ecb..7b2844ed 100644 --- a/App/Controller/IaController.php +++ b/App/Controller/IaController.php @@ -9,7 +9,7 @@ /** * IA Controller - * Handles the AI/ML analysis page (aes2vec / Doc2Vec) + * Handles the AI/ML clustering pipeline (Doc2Vec → KMeans → t-SNE) */ class IaController extends AbstractController { @@ -21,9 +21,7 @@ public function __construct() } /** - * Show the IA dashboard page - * - * @return void + * Show the IA page with "Cartographie des codes" tab */ public function index(): void { @@ -31,19 +29,30 @@ public function index(): void $pdo = DatabaseConnection::getInstance()->getConnection(); - // Statistiques globales + // 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(); + $totalExercises = (int)$pdo->query("SELECT COUNT(*) FROM exercises")->fetchColumn(); + $totalStudents = (int)$pdo->query("SELECT COUNT(DISTINCT student_id) FROM attempts")->fetchColumn(); // Répartition par eval_set $evalSets = $pdo->query( - "SELECT eval_set, COUNT(*) AS count FROM attempts GROUP BY eval_set ORDER BY eval_set" + "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 disponibles (pour le sélecteur) + // Ressources $resources = $pdo->query( - "SELECT ressource_id, ressource_name FROM ressources ORDER BY ressource_name ASC" + "SELECT resource_id, resource_name FROM resources ORDER BY resource_name ASC" + )->fetchAll(\PDO::FETCH_ASSOC); + + // Exercices (tous, pour le sélecteur dynamique côté JS) + $exercises = $pdo->query( + "SELECT e.exercise_id, e.exo_name, e.resource_id, r.resource_name, + COUNT(a.attempt_id) AS nb_attempts + FROM exercises e + LEFT JOIN resources r ON e.resource_id = r.resource_id + LEFT JOIN attempts a ON a.exercise_id = e.exercise_id AND a.aes2 IS NOT NULL AND a.aes2 != '' + GROUP BY e.exercise_id + ORDER BY r.resource_name ASC, e.exo_name ASC" )->fetchAll(\PDO::FETCH_ASSOC); $this->renderView('user/ia', [ @@ -54,7 +63,85 @@ public function index(): void 'eval_sets' => $evalSets, ], 'resources' => $resources, + 'exercises' => $exercises, ]); } -} + /** + * API endpoint : POST /api/ia/clustering + * Appelle le script Python clustering_pipeline.py et renvoie le JSON résultat. + */ + public function clustering(): void + { + $this->authService->requireAuth('/auth/login'); + + if (!$this->isPost()) { + $this->jsonError('Méthode non autorisée', 405); + return; + } + + // Lire le body JSON + $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; + } + + // Chemin du script Python et du venv + $projectRoot = realpath(__DIR__ . '/../../'); + $scriptPath = $projectRoot . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'clustering_pipeline.py'; + $pythonPath = $projectRoot . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'venv' . DIRECTORY_SEPARATOR . 'Scripts' . DIRECTORY_SEPARATOR . 'python.exe'; + + // Fallback si pas sur Windows + if (!file_exists($pythonPath)) { + $pythonPath = $projectRoot . '/scripts/venv/bin/python3'; + } + // Fallback système + if (!file_exists($pythonPath)) { + $pythonPath = 'python'; + } + + if (!file_exists($scriptPath)) { + $this->jsonError('Script clustering_pipeline.py introuvable', 500); + return; + } + + // Construire la commande + $cmd = sprintf( + '%s %s --exercise_id %d --n_clusters %d --perplexity %d 2>&1', + escapeshellarg($pythonPath), + escapeshellarg($scriptPath), + $exerciseId, + $nClusters, + $perplexity + ); + + // Exécuter + $output = []; + $exitCode = 0; + exec($cmd, $output, $exitCode); + + $rawOutput = implode("\n", $output); + + // Chercher le JSON dans la sortie (ignorer les warnings éventuels) + $jsonStart = strpos($rawOutput, '{'); + if ($jsonStart === false) { + $this->jsonError('Le script Python n\'a pas renvoyé de JSON valide. Sortie: ' . substr($rawOutput, 0, 500), 500); + return; + } + + $jsonStr = substr($rawOutput, $jsonStart); + $result = json_decode($jsonStr, true); + + if ($result === null) { + $this->jsonError('JSON invalide du script Python. Sortie: ' . substr($rawOutput, 0, 500), 500); + return; + } + + $this->jsonResponse($result); + } +} diff --git a/App/View/user/ia.php b/App/View/user/ia.php index e8a5bc78..649e5ecc 100644 --- a/App/View/user/ia.php +++ b/App/View/user/ia.php @@ -9,6 +9,7 @@ $stats = $stats ?? []; $resources = $resources ?? []; +$exercises = $exercises ?? []; ?> @@ -22,159 +23,90 @@ @@ -204,95 +136,170 @@
-
-

Analyse par IA

+
+

Intelligence Artificielle

- Visualisez les trajectoires d'apprentissage des étudiants grâce au modèle - aes2vec (Doc2Vec), entraîné sur les tentatives importées. + 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.

- -
+ +
- -
-

Analyse d'un étudiant

-

- Sélectionnez une ressource et un étudiant pour visualiser ses performances - et sa position dans l'espace d'embeddings. -

- -
-
- - +
+
+

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. +

+ +
- - - - - + + + + + - + + + + + + + + + +
ExerciceTentativesRéussiesTaux de réussite
ExerciceRessourceTentatives AESAction
+ + + + = 5) : ?> + + + Min. 5 tentatives + +
+ +

Aucun exercice trouvé.

+
+ + +
+ +
+

Cartographie des codes

+

+ Sélectionnez un exercice pour regrouper les tentatives des élèves par stratégie/erreur. + Le pipeline vectorise les séquences AES avec Doc2Vec, regroupe avec + K-Means, puis projette en 2D avec t-SNE. +

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+
Analyse en cours…
+
Entraînement Doc2Vec → K-Means → t-SNE (peut prendre 10-30 secondes)
+
+ + +
+ + +
+
+ Scatter plot t-SNE des clusters +
+
+
+
+ +
@@ -306,103 +313,136 @@ - diff --git a/App/routes.php b/App/routes.php index a5937ae5..e085ab22 100644 --- a/App/routes.php +++ b/App/routes.php @@ -36,6 +36,9 @@ // IA route $router->get('/ia', App\Controller\IaController::class, 'index'); +// IA API route (clustering pipeline) +$router->post('/api/ia/clustering', App\Controller\IaController::class, 'clustering'); + // Exercise routes $router->get('/exercises', App\Controller\ExercisesController::class, 'index'); $router->get('/exercises/{id}', App\Controller\ExercisesController::class, 'show'); diff --git a/scripts/clustering_pipeline.py b/scripts/clustering_pipeline.py new file mode 100644 index 00000000..49710949 --- /dev/null +++ b/scripts/clustering_pipeline.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +# -*- coding: UTF-8 -*- +""" +clustering_pipeline.py +Pipeline Data Science : Doc2Vec -> KMeans -> t-SNE -> scatter plot base64 + +Usage : + python clustering_pipeline.py --exercise_id [--n_clusters 8] [--perplexity 30] + +Renvoie un JSON sur stdout : +{ + "success": true, + "image_base64": "data:image/png;base64,...", + "n_points": 123, + "clusters": [0,1,2,...], + "students": ["stu1","stu2",...], + "exercise_name": "exo_foo" +} +""" + +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) + +# ── Lecture du .env ────────────────────────────────────────────────────────── +def load_env(): + """Lit le fichier config/.env du projet et renvoie un dict.""" + env_path = os.path.join(PROJECT_ROOT, 'config', '.env') + config = {} + if not os.path.exists(env_path): + # Fallback : valeurs par défaut XAMPP + return { + 'DB_HOST': 'localhost', + 'DB_NAME': 'studtraj', + 'DB_USER': 'root', + 'DB_PASS': '', + } + 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) + key = key.strip() + val = val.strip().strip('"').strip("'") + config[key] = val + return config + +# ── Connexion MySQL ────────────────────────────────────────────────────────── +def get_connection(env): + import mysql.connector + return mysql.connector.connect( + host=env.get('DB_HOST', 'localhost'), + user=env.get('DB_USER', 'root'), + password=env.get('DB_PASS', ''), + database=env.get('DB_NAME', 'studtraj'), + charset='utf8mb4', + ) + +# ── Chargement des tentatives depuis la BD ─────────────────────────────────── +def load_attempts_from_db(conn, exercise_id): + """ + Charge les tentatives pour un exercice donné. + Renvoie une liste de dicts compatibles avec manage.py / aes2vec.py : + { 'aes2': '...', 'eval_set': 'training'|'test', 'user_id': '...', 'correct': 0|1, ... } + """ + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT + a.attempt_id, + a.aes2, + a.eval_set, + a.correct, + a.student_id, + a.exercise_id, + e.exo_name AS exercise_name, + s.student_identifier AS user_id + FROM attempts a + JOIN exercises e ON a.exercise_id = e.exercise_id + JOIN students s ON a.student_id = s.student_id + WHERE a.exercise_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 + +# ── Pipeline principal ─────────────────────────────────────────────────────── +def run_pipeline(exercise_id, n_clusters=8, perplexity=30): + """Exécute le pipeline complet et renvoie le dict résultat.""" + + # 1) Charger les données + env = load_env() + conn = get_connection(env) + data = load_attempts_from_db(conn, exercise_id) + conn.close() + + if len(data) < 5: + return { + 'success': False, + 'error': f"Pas assez de tentatives avec AES pour cet exercice ({len(data)} trouvées, minimum 5)." + } + + exercise_name = data[0].get('exercise_name', f'exercise_{exercise_id}') + + # 2) Préparer les données au format attendu par aes2vec + # On met toutes les tentatives en "training" pour learnModel, + # puis en "test" pour inferVectors (on veut les vecteurs de TOUTES les tentatives). + for att in data: + if not att.get('eval_set'): + att['eval_set'] = 'training' + + # Copie avec toutes les tentatives marquées "training" pour l'entraînement + train_data = [] + for att in data: + d = dict(att) + d['eval_set'] = 'training' + train_data.append(d) + + # Copie avec toutes les tentatives marquées "test" pour l'inférence + infer_data = [] + for att in data: + d = dict(att) + d['eval_set'] = 'test' + infer_data.append(d) + + # 3) Doc2Vec : entraînement + inférence + # On change le répertoire de travail pour que les .cor soient créés dans scripts/ + old_cwd = os.getcwd() + os.chdir(SCRIPT_DIR) + + try: + from aes2vec import learnModel, inferVectors + import numpy as np + + # Entraîner sur toutes les tentatives + model = learnModel( + train_data, + selectionfield='eval_set', + selectionsets=['training'], + valuefield='aes2', + vsize=100, + cwindow=5, + niter=100 # Réduit pour la vitesse en mode interactif + ) + + # Inférer les vecteurs de toutes les tentatives + 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)})." + } + + # 4) 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) + + # 5) t-SNE réduction 2D + from sklearn.manifold import TSNE + + actual_perplexity = min(perplexity, max(1, len(vectors) - 1)) + tsne = TSNE(n_components=2, perplexity=actual_perplexity, random_state=42) + coords_2d = tsne.fit_transform(vectors) + + # 6) Génération du scatter plot matplotlib → base64 + import matplotlib + matplotlib.use('Agg') # Backend sans GUI + 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() + + # Convertir en base64 + 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() + + # 7) Préparer les métadonnées par point (pour le tooltip éventuel côté frontend) + students = [att.get('user_id', '?') for att in data] + correct_list = [int(att.get('correct', 0)) for att in data] + + return { + 'success': True, + '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, + } + + +# ── Point d'entrée CLI ────────────────────────────────────────────────────── +if __name__ == '__main__': + parser = argparse.ArgumentParser(description='Pipeline clustering aes2vec') + parser.add_argument('--exercise_id', type=int, required=True, + help='ID de l\'exercice à analyser') + parser.add_argument('--n_clusters', type=int, default=8, + help='Nombre de clusters KMeans (défaut: 8)') + parser.add_argument('--perplexity', type=int, default=30, + help='Perplexité t-SNE (défaut: 30)') + args = parser.parse_args() + + try: + result = run_pipeline(args.exercise_id, args.n_clusters, args.perplexity) + except Exception as e: + result = { + 'success': False, + 'error': str(e), + } + + print(json.dumps(result, ensure_ascii=False)) + From eb3be367ec0024de0741fb2e5acfbc5b061d2def Mon Sep 17 00:00:00 2001 From: PibouleauJB Date: Mon, 9 Mar 2026 13:52:57 +0100 Subject: [PATCH 03/29] Fix #1 --- App/Controller/IaController.php | 18 +++++++++--------- App/View/user/ia.php | 22 +++++++++++----------- scripts/clustering_pipeline.py | 9 ++++----- 3 files changed, 24 insertions(+), 25 deletions(-) diff --git a/App/Controller/IaController.php b/App/Controller/IaController.php index 7b2844ed..c02f8b4e 100644 --- a/App/Controller/IaController.php +++ b/App/Controller/IaController.php @@ -31,8 +31,8 @@ public function index(): void // Stats globales $totalAttempts = (int)$pdo->query("SELECT COUNT(*) FROM attempts")->fetchColumn(); - $totalExercises = (int)$pdo->query("SELECT COUNT(*) FROM exercises")->fetchColumn(); - $totalStudents = (int)$pdo->query("SELECT COUNT(DISTINCT student_id) 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( @@ -41,18 +41,18 @@ public function index(): void // Ressources $resources = $pdo->query( - "SELECT resource_id, resource_name FROM resources ORDER BY resource_name ASC" + "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.exercise_id, e.exo_name, e.resource_id, r.resource_name, + "SELECT e.exercice_id, e.exercice_name, e.ressource_id, r.ressource_name, COUNT(a.attempt_id) AS nb_attempts - FROM exercises e - LEFT JOIN resources r ON e.resource_id = r.resource_id - LEFT JOIN attempts a ON a.exercise_id = e.exercise_id AND a.aes2 IS NOT NULL AND a.aes2 != '' - GROUP BY e.exercise_id - ORDER BY r.resource_name ASC, e.exo_name ASC" + 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', [ diff --git a/App/View/user/ia.php b/App/View/user/ia.php index 649e5ecc..d5b54c70 100644 --- a/App/View/user/ia.php +++ b/App/View/user/ia.php @@ -207,15 +207,15 @@ - - + + = 5) : ?> - @@ -252,8 +252,8 @@ @@ -329,9 +329,9 @@ function switchIaTab(tabName) { // ── Raccourci depuis le tableau : aller à l'onglet clustering + pré-sélectionner ── function goToCluster(exerciseId) { // Trouver l'exercice - const exo = ALL_EXERCISES.find(e => parseInt(e.exercise_id) === exerciseId); + const exo = ALL_EXERCISES.find(e => parseInt(e.exercice_id) === exerciseId); if (exo) { - document.getElementById('clusterResource').value = exo.resource_id ?? ''; + document.getElementById('clusterResource').value = exo.ressource_id ?? ''; } filterExercises(); @@ -354,15 +354,15 @@ function filterExercises() { sel.innerHTML = ''; const filtered = ALL_EXERCISES.filter(e => { - if (rid && String(e.resource_id) !== String(rid)) return false; + if (rid && String(e.ressource_id) !== String(rid)) return false; return parseInt(e.nb_attempts) >= 5; }); filtered.forEach(e => { const opt = document.createElement('option'); - opt.value = e.exercise_id; - const res = e.resource_name ? ` [${e.resource_name}]` : ''; - opt.textContent = `${e.exo_name}${res} — ${e.nb_attempts} tentatives`; + opt.value = e.exercice_id; + const res = e.ressource_name ? ` [${e.ressource_name}]` : ''; + opt.textContent = `${e.exercice_name}${res} — ${e.nb_attempts} tentatives`; sel.appendChild(opt); }); diff --git a/scripts/clustering_pipeline.py b/scripts/clustering_pipeline.py index 49710949..4908ad60 100644 --- a/scripts/clustering_pipeline.py +++ b/scripts/clustering_pipeline.py @@ -86,13 +86,13 @@ def load_attempts_from_db(conn, exercise_id): a.eval_set, a.correct, a.student_id, - a.exercise_id, - e.exo_name AS exercise_name, + a.exercice_id, + e.exercice_name AS exercise_name, s.student_identifier AS user_id FROM attempts a - JOIN exercises e ON a.exercise_id = e.exercise_id + JOIN exercices e ON a.exercice_id = e.exercice_id JOIN students s ON a.student_id = s.student_id - WHERE a.exercise_id = %s + WHERE a.exercice_id = %s AND a.aes2 IS NOT NULL AND a.aes2 != '' ORDER BY a.attempt_id @@ -268,4 +268,3 @@ def run_pipeline(exercise_id, n_clusters=8, perplexity=30): } print(json.dumps(result, ensure_ascii=False)) - From 43740f8a8d7079d238ebcf509db04bb4ce45c2c0 Mon Sep 17 00:00:00 2001 From: PibouleauJB Date: Mon, 9 Mar 2026 14:14:49 +0100 Subject: [PATCH 04/29] Fix #2 --- App/Controller/IaController.php | 88 ++++++++++--- scripts/clustering_pipeline.py | 220 ++++++++++++++++---------------- 2 files changed, 178 insertions(+), 130 deletions(-) diff --git a/App/Controller/IaController.php b/App/Controller/IaController.php index c02f8b4e..94710c2a 100644 --- a/App/Controller/IaController.php +++ b/App/Controller/IaController.php @@ -69,7 +69,8 @@ public function index(): void /** * API endpoint : POST /api/ia/clustering - * Appelle le script Python clustering_pipeline.py et renvoie le JSON résultat. + * PHP extrait les données de la BD, les passe au script Python via stdin. + * Le script Python fait Doc2Vec → KMeans → t-SNE → image base64. */ public function clustering(): void { @@ -91,43 +92,96 @@ public function clustering(): void return; } - // Chemin du script Python et du venv + // ── Extraire les données depuis la BD (PHP a déjà la connexion) ── + $pdo = DatabaseConnection::getInstance()->getConnection(); + + $stmt = $pdo->prepare(" + SELECT + a.attempt_id, + a.aes2, + a.eval_set, + a.correct, + a.student_id, + a.exercice_id, + e.exercice_name AS exercise_name, + COALESCE(s.student_identifier, CONCAT('student_', a.student_id)) AS user_id + FROM attempts a + JOIN exercices e ON a.exercice_id = e.exercice_id + LEFT JOIN students s ON a.student_id = s.student_id + WHERE a.exercice_id = :eid + AND a.aes2 IS NOT NULL + AND a.aes2 != '' + ORDER BY 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; + } + + // Préparer le payload JSON à envoyer au script Python via stdin + $payload = json_encode([ + 'attempts' => $attempts, + 'n_clusters' => $nClusters, + 'perplexity' => $perplexity, + 'exercise_id' => $exerciseId, + ], JSON_UNESCAPED_UNICODE); + + // ── Chemins Python ── $projectRoot = realpath(__DIR__ . '/../../'); $scriptPath = $projectRoot . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'clustering_pipeline.py'; $pythonPath = $projectRoot . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'venv' . DIRECTORY_SEPARATOR . 'Scripts' . DIRECTORY_SEPARATOR . 'python.exe'; - // Fallback si pas sur Windows if (!file_exists($pythonPath)) { $pythonPath = $projectRoot . '/scripts/venv/bin/python3'; } - // Fallback système if (!file_exists($pythonPath)) { $pythonPath = 'python'; } - if (!file_exists($scriptPath)) { $this->jsonError('Script clustering_pipeline.py introuvable', 500); return; } - // Construire la commande + // ── Exécuter via proc_open pour pouvoir écrire sur stdin ── $cmd = sprintf( - '%s %s --exercise_id %d --n_clusters %d --perplexity %d 2>&1', + '%s %s --from-stdin 2>&1', escapeshellarg($pythonPath), - escapeshellarg($scriptPath), - $exerciseId, - $nClusters, - $perplexity + escapeshellarg($scriptPath) ); - // Exécuter - $output = []; - $exitCode = 0; - exec($cmd, $output, $exitCode); + $descriptors = [ + 0 => ['pipe', 'r'], // stdin + 1 => ['pipe', 'w'], // stdout + 2 => ['pipe', 'w'], // stderr + ]; + + $process = proc_open($cmd, $descriptors, $pipes); + + if (!is_resource($process)) { + $this->jsonError('Impossible de lancer le script Python', 500); + return; + } + + // Écrire les données sur stdin et fermer + fwrite($pipes[0], $payload); + fclose($pipes[0]); + + // Lire stdout + $stdout = stream_get_contents($pipes[1]); + fclose($pipes[1]); + + // Lire stderr + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[2]); + + proc_close($process); - $rawOutput = implode("\n", $output); + $rawOutput = $stdout . $stderr; - // Chercher le JSON dans la sortie (ignorer les warnings éventuels) + // Chercher le JSON dans la sortie $jsonStart = strpos($rawOutput, '{'); if ($jsonStart === false) { $this->jsonError('Le script Python n\'a pas renvoyé de JSON valide. Sortie: ' . substr($rawOutput, 0, 500), 500); diff --git a/scripts/clustering_pipeline.py b/scripts/clustering_pipeline.py index 4908ad60..58b14e53 100644 --- a/scripts/clustering_pipeline.py +++ b/scripts/clustering_pipeline.py @@ -4,8 +4,9 @@ clustering_pipeline.py Pipeline Data Science : Doc2Vec -> KMeans -> t-SNE -> scatter plot base64 -Usage : - python clustering_pipeline.py --exercise_id [--n_clusters 8] [--perplexity 30] +Deux modes d'utilisation : + 1) --from-stdin : reçoit les données JSON depuis stdin (envoyé par PHP) + 2) --exercise_id : se connecte directement à MySQL (usage CLI autonome) Renvoie un JSON sur stdout : { @@ -35,81 +36,10 @@ # S'assurer que le dossier utils/ existe pour les fichiers .cor temporaires os.makedirs(os.path.join(SCRIPT_DIR, 'utils'), exist_ok=True) -# ── Lecture du .env ────────────────────────────────────────────────────────── -def load_env(): - """Lit le fichier config/.env du projet et renvoie un dict.""" - env_path = os.path.join(PROJECT_ROOT, 'config', '.env') - config = {} - if not os.path.exists(env_path): - # Fallback : valeurs par défaut XAMPP - return { - 'DB_HOST': 'localhost', - 'DB_NAME': 'studtraj', - 'DB_USER': 'root', - 'DB_PASS': '', - } - 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) - key = key.strip() - val = val.strip().strip('"').strip("'") - config[key] = val - return config - -# ── Connexion MySQL ────────────────────────────────────────────────────────── -def get_connection(env): - import mysql.connector - return mysql.connector.connect( - host=env.get('DB_HOST', 'localhost'), - user=env.get('DB_USER', 'root'), - password=env.get('DB_PASS', ''), - database=env.get('DB_NAME', 'studtraj'), - charset='utf8mb4', - ) - -# ── Chargement des tentatives depuis la BD ─────────────────────────────────── -def load_attempts_from_db(conn, exercise_id): - """ - Charge les tentatives pour un exercice donné. - Renvoie une liste de dicts compatibles avec manage.py / aes2vec.py : - { 'aes2': '...', 'eval_set': 'training'|'test', 'user_id': '...', 'correct': 0|1, ... } - """ - 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 - -# ── Pipeline principal ─────────────────────────────────────────────────────── -def run_pipeline(exercise_id, n_clusters=8, perplexity=30): - """Exécute le pipeline complet et renvoie le dict résultat.""" - # 1) Charger les données - env = load_env() - conn = get_connection(env) - data = load_attempts_from_db(conn, exercise_id) - conn.close() +# ── Pipeline principal (indépendant de la source de données) ───────────────── +def run_pipeline(data, n_clusters=8, perplexity=30, exercise_id=None): + """Exécute le pipeline complet à partir d'une liste de dicts et renvoie le résultat.""" if len(data) < 5: return { @@ -119,29 +49,18 @@ def run_pipeline(exercise_id, n_clusters=8, perplexity=30): exercise_name = data[0].get('exercise_name', f'exercise_{exercise_id}') - # 2) Préparer les données au format attendu par aes2vec - # On met toutes les tentatives en "training" pour learnModel, - # puis en "test" pour inferVectors (on veut les vecteurs de TOUTES les tentatives). + # 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' - # Copie avec toutes les tentatives marquées "training" pour l'entraînement - train_data = [] - for att in data: - d = dict(att) - d['eval_set'] = 'training' - train_data.append(d) + # Copie "training" pour l'entraînement + train_data = [dict(att, eval_set='training') for att in data] - # Copie avec toutes les tentatives marquées "test" pour l'inférence - infer_data = [] - for att in data: - d = dict(att) - d['eval_set'] = 'test' - infer_data.append(d) + # Copie "test" pour l'inférence + infer_data = [dict(att, eval_set='test') for att in data] - # 3) Doc2Vec : entraînement + inférence - # On change le répertoire de travail pour que les .cor soient créés dans scripts/ + # 2) Doc2Vec : entraînement + inférence old_cwd = os.getcwd() os.chdir(SCRIPT_DIR) @@ -149,7 +68,6 @@ def run_pipeline(exercise_id, n_clusters=8, perplexity=30): from aes2vec import learnModel, inferVectors import numpy as np - # Entraîner sur toutes les tentatives model = learnModel( train_data, selectionfield='eval_set', @@ -157,10 +75,9 @@ def run_pipeline(exercise_id, n_clusters=8, perplexity=30): valuefield='aes2', vsize=100, cwindow=5, - niter=100 # Réduit pour la vitesse en mode interactif + niter=100 ) - # Inférer les vecteurs de toutes les tentatives vectors = inferVectors( model, infer_data, @@ -179,23 +96,23 @@ def run_pipeline(exercise_id, n_clusters=8, perplexity=30): 'error': f"Pas assez de vecteurs générés ({len(vectors)})." } - # 4) KMeans clustering + # 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) - # 5) t-SNE réduction 2D + # 4) t-SNE réduction 2D from sklearn.manifold import TSNE actual_perplexity = min(perplexity, max(1, len(vectors) - 1)) tsne = TSNE(n_components=2, perplexity=actual_perplexity, random_state=42) coords_2d = tsne.fit_transform(vectors) - # 6) Génération du scatter plot matplotlib → base64 + # 5) Génération du scatter plot matplotlib → base64 import matplotlib - matplotlib.use('Agg') # Backend sans GUI + matplotlib.use('Agg') import matplotlib.pyplot as plt import matplotlib.cm as cm @@ -224,7 +141,6 @@ def run_pipeline(exercise_id, n_clusters=8, perplexity=30): ax.grid(True, alpha=0.3) fig.tight_layout() - # Convertir en base64 buf = io.BytesIO() fig.savefig(buf, format='png', dpi=120, bbox_inches='tight') plt.close(fig) @@ -232,7 +148,7 @@ def run_pipeline(exercise_id, n_clusters=8, perplexity=30): img_base64 = 'data:image/png;base64,' + base64.b64encode(buf.read()).decode('utf-8') buf.close() - # 7) Préparer les métadonnées par point (pour le tooltip éventuel côté frontend) + # 6) Métadonnées students = [att.get('user_id', '?') for att in data] correct_list = [int(att.get('correct', 0)) for att in data] @@ -248,23 +164,101 @@ def run_pipeline(exercise_id, n_clusters=8, perplexity=30): } +# ── Lecture depuis stdin (mode appelé par PHP) ─────────────────────────────── +def run_from_stdin(): + """Lit le JSON depuis stdin et lance le pipeline.""" + raw = sys.stdin.read() + payload = json.loads(raw) + data = payload['attempts'] + n_clusters = int(payload.get('n_clusters', 8)) + perplexity = int(payload.get('perplexity', 30)) + exercise_id = payload.get('exercise_id') + return run_pipeline(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(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('--exercise_id', type=int, required=True, - help='ID de l\'exercice à analyser') - parser.add_argument('--n_clusters', type=int, default=8, - help='Nombre de clusters KMeans (défaut: 8)') - parser.add_argument('--perplexity', type=int, default=30, - help='Perplexité t-SNE (défaut: 30)') + 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: - result = run_pipeline(args.exercise_id, args.n_clusters, args.perplexity) + 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), - } + result = {'success': False, 'error': str(e)} print(json.dumps(result, ensure_ascii=False)) From 91a38385d71394e034257b4d76c94f98eaa9951f Mon Sep 17 00:00:00 2001 From: PibouleauJB Date: Mon, 9 Mar 2026 14:23:49 +0100 Subject: [PATCH 05/29] Fix #3 --- App/Controller/IaController.php | 259 +++++++++++++++++--------------- App/View/user/ia.php | 10 +- index.php | 32 ++++ 3 files changed, 183 insertions(+), 118 deletions(-) diff --git a/App/Controller/IaController.php b/App/Controller/IaController.php index 94710c2a..acd3c1c4 100644 --- a/App/Controller/IaController.php +++ b/App/Controller/IaController.php @@ -32,7 +32,7 @@ public function index(): void // 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(); + $totalStudents = (int)$pdo->query("SELECT COUNT(DISTINCT student_id) FROM attempts")->fetchColumn(); // Répartition par eval_set $evalSets = $pdo->query( @@ -74,128 +74,153 @@ public function index(): void */ public function clustering(): void { - $this->authService->requireAuth('/auth/login'); - - if (!$this->isPost()) { - $this->jsonError('Méthode non autorisée', 405); - return; - } - - // Lire le body JSON - $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; - } - - // ── Extraire les données depuis la BD (PHP a déjà la connexion) ── - $pdo = DatabaseConnection::getInstance()->getConnection(); - - $stmt = $pdo->prepare(" - SELECT - a.attempt_id, - a.aes2, - a.eval_set, - a.correct, - a.student_id, - a.exercice_id, - e.exercice_name AS exercise_name, - COALESCE(s.student_identifier, CONCAT('student_', a.student_id)) AS user_id - FROM attempts a - JOIN exercices e ON a.exercice_id = e.exercice_id - LEFT JOIN students s ON a.student_id = s.student_id - WHERE a.exercice_id = :eid - AND a.aes2 IS NOT NULL - AND a.aes2 != '' - ORDER BY 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; - } - - // Préparer le payload JSON à envoyer au script Python via stdin - $payload = json_encode([ - 'attempts' => $attempts, - 'n_clusters' => $nClusters, - 'perplexity' => $perplexity, - 'exercise_id' => $exerciseId, - ], JSON_UNESCAPED_UNICODE); - - // ── Chemins Python ── - $projectRoot = realpath(__DIR__ . '/../../'); - $scriptPath = $projectRoot . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'clustering_pipeline.py'; - $pythonPath = $projectRoot . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'venv' . DIRECTORY_SEPARATOR . 'Scripts' . DIRECTORY_SEPARATOR . 'python.exe'; - - if (!file_exists($pythonPath)) { - $pythonPath = $projectRoot . '/scripts/venv/bin/python3'; - } - if (!file_exists($pythonPath)) { - $pythonPath = 'python'; - } - if (!file_exists($scriptPath)) { - $this->jsonError('Script clustering_pipeline.py introuvable', 500); + // Pour les endpoints API, renvoyer du JSON au lieu de rediriger + if (!$this->authService->isAuthenticated()) { + $this->jsonError('Non authentifié', 401); return; } - // ── Exécuter via proc_open pour pouvoir écrire sur stdin ── - $cmd = sprintf( - '%s %s --from-stdin 2>&1', - escapeshellarg($pythonPath), - escapeshellarg($scriptPath) - ); - - $descriptors = [ - 0 => ['pipe', 'r'], // stdin - 1 => ['pipe', 'w'], // stdout - 2 => ['pipe', 'w'], // stderr - ]; - - $process = proc_open($cmd, $descriptors, $pipes); - - if (!is_resource($process)) { - $this->jsonError('Impossible de lancer le script Python', 500); - return; - } - - // Écrire les données sur stdin et fermer - fwrite($pipes[0], $payload); - fclose($pipes[0]); - - // Lire stdout - $stdout = stream_get_contents($pipes[1]); - fclose($pipes[1]); - - // Lire stderr - $stderr = stream_get_contents($pipes[2]); - fclose($pipes[2]); - - proc_close($process); - - $rawOutput = $stdout . $stderr; - - // Chercher le JSON dans la sortie - $jsonStart = strpos($rawOutput, '{'); - if ($jsonStart === false) { - $this->jsonError('Le script Python n\'a pas renvoyé de JSON valide. Sortie: ' . substr($rawOutput, 0, 500), 500); + if (!$this->isPost()) { + $this->jsonError('Méthode non autorisée', 405); return; } - $jsonStr = substr($rawOutput, $jsonStart); - $result = json_decode($jsonStr, true); - - if ($result === null) { - $this->jsonError('JSON invalide du script Python. Sortie: ' . substr($rawOutput, 0, 500), 500); - return; + try { + // Lire le body JSON + $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; + } + + // ── Extraire les données depuis la BD (PHP a déjà la connexion) ── + $pdo = DatabaseConnection::getInstance()->getConnection(); + + $stmt = $pdo->prepare(" + SELECT + a.attempt_id, + a.aes2, + a.eval_set, + a.correct, + a.student_id, + a.exercice_id, + e.exercice_name AS exercise_name, + COALESCE(s.student_identifier, CONCAT('student_', a.student_id)) AS user_id + FROM attempts a + JOIN exercices e ON a.exercice_id = e.exercice_id + LEFT JOIN students s ON a.student_id = s.student_id + WHERE a.exercice_id = :eid + AND a.aes2 IS NOT NULL + AND a.aes2 != '' + ORDER BY 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; + } + + // Préparer le payload JSON à envoyer au script Python via stdin + $payload = json_encode([ + 'attempts' => $attempts, + 'n_clusters' => $nClusters, + 'perplexity' => $perplexity, + 'exercise_id' => $exerciseId, + ], JSON_UNESCAPED_UNICODE); + + // ── Chemins Python ── + $projectRoot = realpath(__DIR__ . '/../../'); + $scriptPath = $projectRoot . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'clustering_pipeline.py'; + + // Chercher le venv Python dans plusieurs emplacements possibles + $possiblePythonPaths = [ + $projectRoot . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'venv' . DIRECTORY_SEPARATOR . 'Scripts' . DIRECTORY_SEPARATOR . 'python.exe', + 'C:\\xampp\\htdocs\\BUT3\\venv\\Scripts\\python.exe', + $projectRoot . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'venv' . DIRECTORY_SEPARATOR . 'bin' . DIRECTORY_SEPARATOR . 'python3', + 'C:/xampp/htdocs/BUT3/venv/bin/python3', + ]; + + $pythonPath = 'python'; // fallback + foreach ($possiblePythonPaths as $candidate) { + if (file_exists($candidate)) { + $pythonPath = $candidate; + break; + } + } + + if (!file_exists($scriptPath)) { + $this->jsonError('Script clustering_pipeline.py introuvable : ' . $scriptPath, 500); + return; + } + + // ── Exécuter via proc_open pour pouvoir écrire sur stdin ── + $cmd = sprintf( + '%s %s --from-stdin', + escapeshellarg($pythonPath), + escapeshellarg($scriptPath) + ); + + $descriptors = [ + 0 => ['pipe', 'r'], // stdin + 1 => ['pipe', 'w'], // stdout + 2 => ['pipe', 'w'], // stderr + ]; + + $process = proc_open($cmd, $descriptors, $pipes); + + if (!is_resource($process)) { + $this->jsonError('Impossible de lancer le script Python (commande: ' . $cmd . ')', 500); + return; + } + + // Écrire les données sur stdin et fermer + fwrite($pipes[0], $payload); + fclose($pipes[0]); + + // Lire stdout + $stdout = stream_get_contents($pipes[1]); + fclose($pipes[1]); + + // Lire stderr + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[2]); + + $exitCode = proc_close($process); + + // Chercher le JSON dans stdout d'abord, puis dans 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); + $this->jsonError( + 'Le script Python n\'a pas renvoyé de JSON valide (exit code: ' . $exitCode . '). Sortie: ' . substr($rawOutput, 0, 800), + 500 + ); + return; + } + + $result = json_decode($jsonStr, true); + $this->jsonResponse($result); + + } catch (\Throwable $e) { + $this->jsonError('Erreur serveur : ' . $e->getMessage(), 500); } - - $this->jsonResponse($result); } } diff --git a/App/View/user/ia.php b/App/View/user/ia.php index d5b54c70..9c7221eb 100644 --- a/App/View/user/ia.php +++ b/App/View/user/ia.php @@ -401,7 +401,15 @@ function generateClusters() { perplexity: perplexity, }) }) - .then(r => r.json()) + .then(r => { + const contentType = r.headers.get('content-type') || ''; + if (!contentType.includes('application/json')) { + return r.text().then(text => { + throw new Error('Le serveur a renvoyé du HTML au lieu de JSON (HTTP ' + r.status + '). Vérifiez les logs PHP.'); + }); + } + return r.json(); + }) .then(res => { loading.classList.remove('visible'); btn.disabled = false; diff --git a/index.php b/index.php index fcb9afb0..46f9133d 100644 --- a/index.php +++ b/index.php @@ -18,6 +18,13 @@ error_log('[FATAL] ' . $error['message'] . ' in ' . $error['file'] . ':' . $error['line']); if (!headers_sent()) { http_response_code(500); + // Détecter si c'est une requête API pour renvoyer du JSON + $uri = $_SERVER['REQUEST_URI'] ?? ''; + if (str_contains($uri, '/api/')) { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['success' => false, 'message' => 'Erreur fatale du serveur'], JSON_UNESCAPED_UNICODE); + return; + } header('Content-Type: text/html; charset=utf-8'); } $errorView = __DIR__ . '/App/View/errors/500.php'; @@ -36,6 +43,24 @@ $line = $e->getLine(); error_log('[UNCAUGHT] ' . get_class($e) . ': ' . $msg . ' in ' . $file . ':' . $line); + // Détecter si c'est une requête API pour renvoyer du JSON au lieu de HTML + $uri = $_SERVER['REQUEST_URI'] ?? ''; + if (str_contains($uri, '/api/')) { + 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'); @@ -88,6 +113,13 @@ function () { // Set 404 handler $router->setNotFoundHandler(function() { http_response_code(404); + // Détecter si c'est une requête API pour renvoyer du JSON + $uri = $_SERVER['REQUEST_URI'] ?? ''; + if (str_contains($uri, '/api/')) { + 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 { From 73fe369fd481f5f9f73c0d88f9ab0c55d33ab444 Mon Sep 17 00:00:00 2001 From: PibouleauJB Date: Mon, 9 Mar 2026 14:33:45 +0100 Subject: [PATCH 06/29] Fix #4 --- index.php | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/index.php b/index.php index 46f9133d..bc6f2049 100644 --- a/index.php +++ b/index.php @@ -18,9 +18,8 @@ error_log('[FATAL] ' . $error['message'] . ' in ' . $error['file'] . ':' . $error['line']); if (!headers_sent()) { http_response_code(500); - // Détecter si c'est une requête API pour renvoyer du JSON $uri = $_SERVER['REQUEST_URI'] ?? ''; - if (str_contains($uri, '/api/')) { + 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; @@ -43,9 +42,8 @@ $line = $e->getLine(); error_log('[UNCAUGHT] ' . get_class($e) . ': ' . $msg . ' in ' . $file . ':' . $line); - // Détecter si c'est une requête API pour renvoyer du JSON au lieu de HTML $uri = $_SERVER['REQUEST_URI'] ?? ''; - if (str_contains($uri, '/api/')) { + if (strpos($uri, '/api/') !== false) { if (!headers_sent()) { http_response_code(500); header('Content-Type: application/json; charset=utf-8'); @@ -73,7 +71,6 @@ echo '

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

'; echo '
' . htmlspecialchars($e->getTraceAsString()) . '
'; } else { - // In production, show a generic error page $errorView = __DIR__ . '/App/View/errors/500.php'; if (file_exists($errorView)) { require $errorView; @@ -89,10 +86,6 @@ $router = new Router(); -// --------------------------------------------------------------------------- -// Service Container — register controllers that require dependency injection. -// Controllers not registered here are still instantiated via `new` (fallback). -// --------------------------------------------------------------------------- $container = new Container(); $container->set( @@ -113,9 +106,8 @@ function () { // Set 404 handler $router->setNotFoundHandler(function() { http_response_code(404); - // Détecter si c'est une requête API pour renvoyer du JSON $uri = $_SERVER['REQUEST_URI'] ?? ''; - if (str_contains($uri, '/api/')) { + 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; @@ -129,3 +121,4 @@ function () { // Dispatch the request $router->dispatch(); + From c25c6467cb143d67a02e7379f4339893434d793a Mon Sep 17 00:00:00 2001 From: PibouleauJB Date: Mon, 9 Mar 2026 14:46:56 +0100 Subject: [PATCH 07/29] Fix #4b --- debug_500.php | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 debug_500.php diff --git a/debug_500.php b/debug_500.php new file mode 100644 index 00000000..b0402fe1 --- /dev/null +++ b/debug_500.php @@ -0,0 +1,91 @@ +Diagnostic SAE"; + +// 1. Version PHP +echo "

PHP Version: " . PHP_VERSION . "

"; + +// 2. .env +$envPath = __DIR__ . '/../config/.env'; +echo "

.env path: " . realpath($envPath) . " — " . (file_exists($envPath) ? '✅ EXISTS' : '❌ NOT FOUND') . "

"; + +// 3. Bootstrap +echo "

Bootstrap: "; +try { + require_once __DIR__ . '/App/bootstrap.php'; + echo "✅ OK

"; +} catch (\Throwable $e) { + echo "❌ " . get_class($e) . ": " . $e->getMessage() . "

"; +} + +// 4. DB Connection +echo "

Database: "; +try { + $pdo = \Core\Config\DatabaseConnection::getInstance()->getConnection(); + echo "✅ Connected

"; +} catch (\Throwable $e) { + echo "❌ " . $e->getMessage() . "

"; + die(); +} + +// 5. Tables +$tables = ['attempts', 'exercices', 'ressources', 'students']; +foreach ($tables as $t) { + echo "

Table '$t': "; + try { + $count = $pdo->query("SELECT COUNT(*) FROM $t")->fetchColumn(); + echo "✅ $count rows

"; + } catch (\Throwable $e) { + echo "❌ " . $e->getMessage() . "

"; + } +} + +// 6. Colonne student_id dans attempts +echo "

Colonne student_id dans attempts: "; +try { + $pdo->query("SELECT student_id FROM attempts LIMIT 1")->fetchColumn(); + echo "✅ EXISTS

"; +} catch (\Throwable $e) { + echo "❌ " . $e->getMessage() . "

"; +} + +// 7. Colonne user_id dans attempts (l'ancienne ?) +echo "

Colonne user_id dans attempts: "; +try { + $pdo->query("SELECT user_id FROM attempts LIMIT 1")->fetchColumn(); + echo "⚠️ EXISTS (ancienne colonne ?)

"; +} catch (\Throwable $e) { + echo "— N'existe pas (normal)

"; +} + +// 8. Structure de la table attempts +echo "

Colonnes de attempts: "; +try { + $cols = $pdo->query("SHOW COLUMNS FROM attempts")->fetchAll(\PDO::FETCH_COLUMN); + echo implode(', ', $cols) . "

"; +} catch (\Throwable $e) { + echo "❌ " . $e->getMessage() . "

"; +} + +// 9. Simuler la requête du IaController::index +echo "

Requête COUNT(DISTINCT student_id): "; +try { + $count = $pdo->query("SELECT COUNT(DISTINCT student_id) FROM attempts")->fetchColumn(); + echo "✅ $count

"; +} catch (\Throwable $e) { + echo "❌ " . $e->getMessage() . "

"; +} + +// 10. Session +echo "

Session: "; +session_start(); +echo "is_authenticated = " . var_export($_SESSION['is_authenticated'] ?? false, true) . "

"; + +echo "

Supprimez ce fichier après diagnostic.

"; + From 5f75e049b79f3b995a7aadb7eb3a71da01e91b65 Mon Sep 17 00:00:00 2001 From: PibouleauJB Date: Mon, 9 Mar 2026 14:48:49 +0100 Subject: [PATCH 08/29] Fix #4c --- test_php.php | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 test_php.php diff --git a/test_php.php b/test_php.php new file mode 100644 index 00000000..f5173d03 --- /dev/null +++ b/test_php.php @@ -0,0 +1,5 @@ + Date: Mon, 9 Mar 2026 14:53:06 +0100 Subject: [PATCH 09/29] Fix #4d --- index.php | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/index.php b/index.php index bc6f2049..801878e8 100644 --- a/index.php +++ b/index.php @@ -5,12 +5,21 @@ * Main entry point for the application using Core/App architecture */ +// DEBUG TEMPORAIRE — à retirer après diagnostic +ini_set('display_errors', 1); +error_reporting(E_ALL); + // Start output buffering immediately to prevent "headers already sent" issues ob_start(); // Bootstrap the application require_once __DIR__ . '/App/bootstrap.php'; +// FORCER le mode development pour voir les erreurs détaillées +if (!defined('APP_ENV')) { + define('APP_ENV', 'development'); +} + // Catch fatal errors (E_ERROR, E_PARSE, etc.) that set_exception_handler cannot catch register_shutdown_function(function (): void { $error = error_get_last(); @@ -64,20 +73,11 @@ header('Content-Type: text/html; charset=utf-8'); } - $env = defined('APP_ENV') ? APP_ENV : (\Core\Config\EnvLoader::get('APP_ENV', 'production')); - if ($env === 'development') { - 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()) . '
'; - } else { - $errorView = __DIR__ . '/App/View/errors/500.php'; - if (file_exists($errorView)) { - require $errorView; - } else { - echo '

Erreur interne du serveur

Une erreur est survenue. Veuillez réessayer.

'; - } - } + // 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 @@ -121,4 +121,3 @@ function () { // Dispatch the request $router->dispatch(); - From 939f4f38193cbbf6ea629f68a924dc21cf4e6679 Mon Sep 17 00:00:00 2001 From: PibouleauJB Date: Mon, 9 Mar 2026 15:00:48 +0100 Subject: [PATCH 10/29] Fix #4e --- App/Controller/IaController.php | 24 ++++++++++++------------ App/View/user/ia.php | 22 +++++++++++----------- index.php | 9 --------- 3 files changed, 23 insertions(+), 32 deletions(-) diff --git a/App/Controller/IaController.php b/App/Controller/IaController.php index acd3c1c4..f5969817 100644 --- a/App/Controller/IaController.php +++ b/App/Controller/IaController.php @@ -31,7 +31,7 @@ public function index(): void // Stats globales $totalAttempts = (int)$pdo->query("SELECT COUNT(*) FROM attempts")->fetchColumn(); - $totalExercises = (int)$pdo->query("SELECT COUNT(*) FROM exercices")->fetchColumn(); + $totalExercises = (int)$pdo->query("SELECT COUNT(*) FROM exercises")->fetchColumn(); $totalStudents = (int)$pdo->query("SELECT COUNT(DISTINCT student_id) FROM attempts")->fetchColumn(); // Répartition par eval_set @@ -41,18 +41,18 @@ public function index(): void // Ressources $resources = $pdo->query( - "SELECT ressource_id, ressource_name FROM ressources ORDER BY ressource_name ASC" + "SELECT resource_id, resource_name FROM resources ORDER BY resource_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, e.ressource_id, r.ressource_name, + "SELECT e.exercise_id, e.exo_name, e.resource_id, r.resource_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" + FROM exercises e + LEFT JOIN resources r ON e.resource_id = r.resource_id + LEFT JOIN attempts a ON a.exercise_id = e.exercise_id AND a.aes2 IS NOT NULL AND a.aes2 != '' + GROUP BY e.exercise_id + ORDER BY r.resource_name ASC, e.exo_name ASC" )->fetchAll(\PDO::FETCH_ASSOC); $this->renderView('user/ia', [ @@ -107,13 +107,13 @@ public function clustering(): void a.eval_set, a.correct, a.student_id, - a.exercice_id, - e.exercice_name AS exercise_name, + a.exercise_id, + e.exo_name AS exercise_name, COALESCE(s.student_identifier, CONCAT('student_', a.student_id)) AS user_id FROM attempts a - JOIN exercices e ON a.exercice_id = e.exercice_id + JOIN exercises e ON a.exercise_id = e.exercise_id LEFT JOIN students s ON a.student_id = s.student_id - WHERE a.exercice_id = :eid + WHERE a.exercise_id = :eid AND a.aes2 IS NOT NULL AND a.aes2 != '' ORDER BY a.attempt_id diff --git a/App/View/user/ia.php b/App/View/user/ia.php index 9c7221eb..a73accbd 100644 --- a/App/View/user/ia.php +++ b/App/View/user/ia.php @@ -207,15 +207,15 @@ - - + + = 5) : ?> - @@ -252,8 +252,8 @@ @@ -329,9 +329,9 @@ function switchIaTab(tabName) { // ── Raccourci depuis le tableau : aller à l'onglet clustering + pré-sélectionner ── function goToCluster(exerciseId) { // Trouver l'exercice - const exo = ALL_EXERCISES.find(e => parseInt(e.exercice_id) === exerciseId); + const exo = ALL_EXERCISES.find(e => parseInt(e.exercise_id) === exerciseId); if (exo) { - document.getElementById('clusterResource').value = exo.ressource_id ?? ''; + document.getElementById('clusterResource').value = exo.resource_id ?? ''; } filterExercises(); @@ -354,15 +354,15 @@ function filterExercises() { sel.innerHTML = ''; const filtered = ALL_EXERCISES.filter(e => { - if (rid && String(e.ressource_id) !== String(rid)) return false; + if (rid && String(e.resource_id) !== String(rid)) return false; return parseInt(e.nb_attempts) >= 5; }); filtered.forEach(e => { const opt = document.createElement('option'); - opt.value = e.exercice_id; - const res = e.ressource_name ? ` [${e.ressource_name}]` : ''; - opt.textContent = `${e.exercice_name}${res} — ${e.nb_attempts} tentatives`; + opt.value = e.exercise_id; + const res = e.resource_name ? ` [${e.resource_name}]` : ''; + opt.textContent = `${e.exo_name}${res} — ${e.nb_attempts} tentatives`; sel.appendChild(opt); }); diff --git a/index.php b/index.php index 801878e8..682caa9c 100644 --- a/index.php +++ b/index.php @@ -5,21 +5,12 @@ * Main entry point for the application using Core/App architecture */ -// DEBUG TEMPORAIRE — à retirer après diagnostic -ini_set('display_errors', 1); -error_reporting(E_ALL); - // Start output buffering immediately to prevent "headers already sent" issues ob_start(); // Bootstrap the application require_once __DIR__ . '/App/bootstrap.php'; -// FORCER le mode development pour voir les erreurs détaillées -if (!defined('APP_ENV')) { - define('APP_ENV', 'development'); -} - // Catch fatal errors (E_ERROR, E_PARSE, etc.) that set_exception_handler cannot catch register_shutdown_function(function (): void { $error = error_get_last(); From 161561de2eee547adbae20df7cc1f9571a552109 Mon Sep 17 00:00:00 2001 From: PibouleauJB Date: Mon, 9 Mar 2026 15:09:40 +0100 Subject: [PATCH 11/29] Fix #5 --- App/Controller/IaController.php | 32 +++++++++++++++----------------- App/View/user/ia.php | 23 +++++++++++------------ 2 files changed, 26 insertions(+), 29 deletions(-) diff --git a/App/Controller/IaController.php b/App/Controller/IaController.php index f5969817..6d577399 100644 --- a/App/Controller/IaController.php +++ b/App/Controller/IaController.php @@ -31,8 +31,8 @@ public function index(): void // Stats globales $totalAttempts = (int)$pdo->query("SELECT COUNT(*) FROM attempts")->fetchColumn(); - $totalExercises = (int)$pdo->query("SELECT COUNT(*) FROM exercises")->fetchColumn(); - $totalStudents = (int)$pdo->query("SELECT COUNT(DISTINCT student_id) 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( @@ -41,18 +41,18 @@ public function index(): void // Ressources $resources = $pdo->query( - "SELECT resource_id, resource_name FROM resources ORDER BY resource_name ASC" + "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.exercise_id, e.exo_name, e.resource_id, r.resource_name, + "SELECT e.exercice_id, e.exercice_name, e.ressource_id, r.ressource_name, COUNT(a.attempt_id) AS nb_attempts - FROM exercises e - LEFT JOIN resources r ON e.resource_id = r.resource_id - LEFT JOIN attempts a ON a.exercise_id = e.exercise_id AND a.aes2 IS NOT NULL AND a.aes2 != '' - GROUP BY e.exercise_id - ORDER BY r.resource_name ASC, e.exo_name ASC" + 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', [ @@ -97,7 +97,7 @@ public function clustering(): void return; } - // ── Extraire les données depuis la BD (PHP a déjà la connexion) ── + // ── Extraire les données depuis la BD ── $pdo = DatabaseConnection::getInstance()->getConnection(); $stmt = $pdo->prepare(" @@ -106,14 +106,12 @@ public function clustering(): void a.aes2, a.eval_set, a.correct, - a.student_id, - a.exercise_id, - e.exo_name AS exercise_name, - COALESCE(s.student_identifier, CONCAT('student_', a.student_id)) AS user_id + a.user_id, + a.exercice_id, + e.exercice_name AS exercise_name FROM attempts a - JOIN exercises e ON a.exercise_id = e.exercise_id - LEFT JOIN students s ON a.student_id = s.student_id - WHERE a.exercise_id = :eid + 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.attempt_id diff --git a/App/View/user/ia.php b/App/View/user/ia.php index a73accbd..c0fa09c3 100644 --- a/App/View/user/ia.php +++ b/App/View/user/ia.php @@ -207,15 +207,15 @@ - - + + = 5) : ?> - @@ -252,8 +252,8 @@ @@ -328,10 +328,9 @@ function switchIaTab(tabName) { // ── Raccourci depuis le tableau : aller à l'onglet clustering + pré-sélectionner ── function goToCluster(exerciseId) { - // Trouver l'exercice - const exo = ALL_EXERCISES.find(e => parseInt(e.exercise_id) === exerciseId); + const exo = ALL_EXERCISES.find(e => parseInt(e.exercice_id) === exerciseId); if (exo) { - document.getElementById('clusterResource').value = exo.resource_id ?? ''; + document.getElementById('clusterResource').value = exo.ressource_id ?? ''; } filterExercises(); @@ -354,15 +353,15 @@ function filterExercises() { sel.innerHTML = ''; const filtered = ALL_EXERCISES.filter(e => { - if (rid && String(e.resource_id) !== String(rid)) return false; + if (rid && String(e.ressource_id) !== String(rid)) return false; return parseInt(e.nb_attempts) >= 5; }); filtered.forEach(e => { const opt = document.createElement('option'); - opt.value = e.exercise_id; - const res = e.resource_name ? ` [${e.resource_name}]` : ''; - opt.textContent = `${e.exo_name}${res} — ${e.nb_attempts} tentatives`; + opt.value = e.exercice_id; + const res = e.ressource_name ? ` [${e.ressource_name}]` : ''; + opt.textContent = `${e.exercice_name}${res} — ${e.nb_attempts} tentatives`; sel.appendChild(opt); }); From 9a1d9767521cd53f63744c41dcd98ae777ef0222 Mon Sep 17 00:00:00 2001 From: PibouleauJB Date: Mon, 9 Mar 2026 15:18:02 +0100 Subject: [PATCH 12/29] Fix #5b --- scripts/requirements.txt | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 scripts/requirements.txt 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 + From 135f25bb756d5f2c81399cade0a9fdb7f6a16cd2 Mon Sep 17 00:00:00 2001 From: PibouleauJB Date: Mon, 9 Mar 2026 16:08:54 +0100 Subject: [PATCH 13/29] Fix #5c --- App/Controller/IaController.php | 79 ++++++++++++++++++++++++++++++--- App/routes.php | 3 ++ 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/App/Controller/IaController.php b/App/Controller/IaController.php index 6d577399..6280c1b4 100644 --- a/App/Controller/IaController.php +++ b/App/Controller/IaController.php @@ -67,6 +67,63 @@ public function index(): void ]); } + /** + * TEMPORAIRE — diagnostic Python sur le serveur. + * GET /api/ia/debug-python → JSON avec les chemins testés et le Python utilisé. + * À SUPPRIMER après résolution du problème. + */ + public function debugPython(): void + { + $projectRoot = realpath(__DIR__ . '/../../'); + + $possiblePythonPaths = [ + $projectRoot . '/scripts/venv/bin/python3', + $projectRoot . '/scripts/venv/bin/python', + $projectRoot . '/venv/bin/python3', + $projectRoot . '/venv/bin/python', + '/home/studtraj/venv/bin/python3', + '/home/studtraj/www/venv/bin/python3', + '/home/studtraj/www/SAE/scripts/venv/bin/python3', + $projectRoot . '/scripts/venv/Scripts/python.exe', + ]; + + $results = []; + $chosenPath = 'python'; + foreach ($possiblePythonPaths as $p) { + $exists = file_exists($p); + $results[] = ['path' => $p, 'exists' => $exists]; + if ($exists && $chosenPath === 'python') { + $chosenPath = $p; + } + } + + // Tester which python3 / which python + $whichPython3 = trim(shell_exec('which python3 2>&1') ?? ''); + $whichPython = trim(shell_exec('which python 2>&1') ?? ''); + + // Tester si gensim est disponible avec le python choisi + $testCmd = escapeshellarg($chosenPath) . ' -c "import gensim; print(gensim.__version__)" 2>&1'; + $gensimTest = trim(shell_exec($testCmd) ?? ''); + + // Lister le contenu de scripts/venv/ s'il existe + $venvDir = $projectRoot . '/scripts/venv'; + $venvContents = is_dir($venvDir) ? scandir($venvDir) : 'DOSSIER INEXISTANT'; + $venvBinDir = $venvDir . '/bin'; + $venvBinContents = is_dir($venvBinDir) ? scandir($venvBinDir) : 'DOSSIER bin/ INEXISTANT'; + + $this->jsonResponse([ + 'project_root' => $projectRoot, + 'script_exists' => file_exists($projectRoot . '/scripts/clustering_pipeline.py'), + 'candidates' => $results, + 'chosen_python' => $chosenPath, + 'which_python3' => $whichPython3, + 'which_python' => $whichPython, + 'gensim_test' => $gensimTest, + 'venv_contents' => $venvContents, + 'venv_bin_contents' => $venvBinContents, + ]); + } + /** * API endpoint : POST /api/ia/clustering * PHP extrait les données de la BD, les passe au script Python via stdin. @@ -134,17 +191,29 @@ public function clustering(): void // ── Chemins Python ── $projectRoot = realpath(__DIR__ . '/../../'); - $scriptPath = $projectRoot . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'clustering_pipeline.py'; + $scriptPath = $projectRoot . '/scripts/clustering_pipeline.py'; // Chercher le venv Python dans plusieurs emplacements possibles $possiblePythonPaths = [ - $projectRoot . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'venv' . DIRECTORY_SEPARATOR . 'Scripts' . DIRECTORY_SEPARATOR . 'python.exe', + // Linux : venv dans scripts/ + $projectRoot . '/scripts/venv/bin/python3', + $projectRoot . '/scripts/venv/bin/python', + // Linux : venv à la racine du projet + $projectRoot . '/venv/bin/python3', + $projectRoot . '/venv/bin/python', + // Linux : venv dans le home (Alwaysdata / hébergeur) + '/home/studtraj/venv/bin/python3', + '/home/studtraj/www/venv/bin/python3', + '/home/studtraj/www/SAE/scripts/venv/bin/python3', + // Windows : venv dans scripts/ + $projectRoot . '/scripts/venv/Scripts/python.exe', + // Windows : venv externe 'C:\\xampp\\htdocs\\BUT3\\venv\\Scripts\\python.exe', - $projectRoot . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'venv' . DIRECTORY_SEPARATOR . 'bin' . DIRECTORY_SEPARATOR . 'python3', - 'C:/xampp/htdocs/BUT3/venv/bin/python3', + // python3 système (peut avoir les modules) + 'python3', ]; - $pythonPath = 'python'; // fallback + $pythonPath = 'python'; // fallback ultime foreach ($possiblePythonPaths as $candidate) { if (file_exists($candidate)) { $pythonPath = $candidate; diff --git a/App/routes.php b/App/routes.php index e085ab22..3797d549 100644 --- a/App/routes.php +++ b/App/routes.php @@ -39,6 +39,9 @@ // IA API route (clustering pipeline) $router->post('/api/ia/clustering', App\Controller\IaController::class, 'clustering'); +// IA diagnostic route (TEMPORAIRE - à supprimer après debug) +$router->get('/api/ia/debug-python', App\Controller\IaController::class, 'debugPython'); + // Exercise routes $router->get('/exercises', App\Controller\ExercisesController::class, 'index'); $router->get('/exercises/{id}', App\Controller\ExercisesController::class, 'show'); From c66aa5316955c87e7b3d3df964e2831e1d212d58 Mon Sep 17 00:00:00 2001 From: PibouleauJB Date: Mon, 9 Mar 2026 16:31:41 +0100 Subject: [PATCH 14/29] Fix #6 --- App/Controller/IaController.php | 123 +++++++++++++++++++++++++------- 1 file changed, 97 insertions(+), 26 deletions(-) diff --git a/App/Controller/IaController.php b/App/Controller/IaController.php index 6280c1b4..cd438349 100644 --- a/App/Controller/IaController.php +++ b/App/Controller/IaController.php @@ -193,32 +193,17 @@ public function clustering(): void $projectRoot = realpath(__DIR__ . '/../../'); $scriptPath = $projectRoot . '/scripts/clustering_pipeline.py'; - // Chercher le venv Python dans plusieurs emplacements possibles - $possiblePythonPaths = [ - // Linux : venv dans scripts/ - $projectRoot . '/scripts/venv/bin/python3', - $projectRoot . '/scripts/venv/bin/python', - // Linux : venv à la racine du projet - $projectRoot . '/venv/bin/python3', - $projectRoot . '/venv/bin/python', - // Linux : venv dans le home (Alwaysdata / hébergeur) - '/home/studtraj/venv/bin/python3', - '/home/studtraj/www/venv/bin/python3', - '/home/studtraj/www/SAE/scripts/venv/bin/python3', - // Windows : venv dans scripts/ - $projectRoot . '/scripts/venv/Scripts/python.exe', - // Windows : venv externe - 'C:\\xampp\\htdocs\\BUT3\\venv\\Scripts\\python.exe', - // python3 système (peut avoir les modules) - 'python3', - ]; + // Résolution du binaire Python : on privilégie un venv local, + // puis /usr/bin/python3 (Alwaysdata), puis python3/python du PATH. + $pythonPath = $this->findPython($projectRoot); - $pythonPath = 'python'; // fallback ultime - foreach ($possiblePythonPaths as $candidate) { - if (file_exists($candidate)) { - $pythonPath = $candidate; - break; - } + if ($pythonPath === null) { + $this->jsonError( + 'Aucun interpréteur Python avec gensim trouvé. ' + . 'Vérifiez que gensim est installé : python3 -m pip install --user gensim', + 500 + ); + return; } if (!file_exists($scriptPath)) { @@ -233,13 +218,26 @@ public function clustering(): void escapeshellarg($scriptPath) ); + // Passer les variables d'environnement nécessaires pour que + // Python trouve les packages installés via pip --user + $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'], // stdin 1 => ['pipe', 'w'], // stdout 2 => ['pipe', 'w'], // stderr ]; - $process = proc_open($cmd, $descriptors, $pipes); + $process = proc_open($cmd, $descriptors, $pipes, null, $env); if (!is_resource($process)) { $this->jsonError('Impossible de lancer le script Python (commande: ' . $cmd . ')', 500); @@ -290,4 +288,77 @@ public function clustering(): void $this->jsonError('Erreur serveur : ' . $e->getMessage(), 500); } } + + /** + * 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; + } } From 2f90d4be425e93ab9d041c0c27401188a0504b58 Mon Sep 17 00:00:00 2001 From: PibouleauJB Date: Mon, 9 Mar 2026 23:25:43 +0100 Subject: [PATCH 15/29] =?UTF-8?q?fr:=20Ajout=20des=20modes=20d'analyse=20m?= =?UTF-8?q?acro=20et=20micro=20au=20pipeline=20de=20clustering=20en:=20Add?= =?UTF-8?q?=20macro=20and=20micro=20analysis=20modes=20to=20clustering=20p?= =?UTF-8?q?ipeline=20es:=20Agregar=20modos=20de=20an=C3=A1lisis=20macro=20?= =?UTF-8?q?y=20micro=20al=20pipeline=20de=20clustering=20de:=20Hinzuf?= =?UTF-8?q?=C3=BCgen=20von=20Makro-=20und=20Mikroanalysemodi=20zur=20Clust?= =?UTF-8?q?ering-Pipeline=20it:=20Aggiunta=20delle=20modalit=C3=A0=20di=20?= =?UTF-8?q?analisi=20macro=20e=20micro=20alla=20pipeline=20di=20clustering?= =?UTF-8?q?=20zh:=20=E5=9C=A8=E8=81=9A=E7=B1=BB=E6=B5=81=E6=B0=B4=E7=BA=BF?= =?UTF-8?q?=E4=B8=AD=E6=B7=BB=E5=8A=A0=E5=AE=8F=E8=A7=82=E5=92=8C=E5=BE=AE?= =?UTF-8?q?=E8=A7=82=E5=88=86=E6=9E=90=E6=A8=A1=E5=BC=8F=20ja:=20=E3=82=AF?= =?UTF-8?q?=E3=83=A9=E3=82=B9=E3=82=BF=E3=83=AA=E3=83=B3=E3=82=B0=E3=83=91?= =?UTF-8?q?=E3=82=A4=E3=83=97=E3=83=A9=E3=82=A4=E3=83=B3=E3=81=AB=E3=83=9E?= =?UTF-8?q?=E3=82=AF=E3=83=AD=E3=81=8A=E3=82=88=E3=81=B3=E3=83=9F=E3=82=AF?= =?UTF-8?q?=E3=83=AD=E5=88=86=E6=9E=90=E3=83=A2=E3=83=BC=E3=83=89=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=20ru:=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BC=D0=B0=D0=BA=D1=80=D0=BE-=20?= =?UTF-8?q?=D0=B8=20=D0=BC=D0=B8=D0=BA=D1=80=D0=BE=D1=80=D0=B5=D0=B6=D0=B8?= =?UTF-8?q?=D0=BC=D0=BE=D0=B2=20=D0=B0=D0=BD=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=20=D0=B2=20=D0=BA=D0=BE=D0=BD=D0=B2=D0=B5=D0=B9=D0=B5=D1=80=20?= =?UTF-8?q?=D0=BA=D0=BB=D0=B0=D1=81=D1=82=D0=B5=D1=80=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20ar:=20=D8=A5=D8=B6=D8=A7=D9=81=D8=A9=20?= =?UTF-8?q?=D8=A3=D9=88=D8=B6=D8=A7=D8=B9=20=D8=A7=D9=84=D8=AA=D8=AD=D9=84?= =?UTF-8?q?=D9=8A=D9=84=20=D8=A7=D9=84=D9=83=D9=84=D9=8A=20=D9=88=D8=A7?= =?UTF-8?q?=D9=84=D8=AC=D8=B2=D8=A6=D9=8A=20=D8=A5=D9=84=D9=89=20=D8=AE?= =?UTF-8?q?=D8=B7=20=D8=A3=D9=86=D8=A7=D8=A8=D9=8A=D8=A8=20=D8=A7=D9=84?= =?UTF-8?q?=D8=AA=D8=AC=D9=85=D9=8A=D8=B9=20pt:=20Adicionar=20modos=20de?= =?UTF-8?q?=20an=C3=A1lise=20macro=20e=20micro=20ao=20pipeline=20de=20clus?= =?UTF-8?q?tering=20hi:=20=E0=A4=95=E0=A5=8D=E0=A4=B2=E0=A4=B8=E0=A5=8D?= =?UTF-8?q?=E0=A4=9F=E0=A4=B0=E0=A4=BF=E0=A4=82=E0=A4=97=20=E0=A4=AA?= =?UTF-8?q?=E0=A4=BE=E0=A4=87=E0=A4=AA=E0=A4=B2=E0=A4=BE=E0=A4=87=E0=A4=A8?= =?UTF-8?q?=20=E0=A4=AE=E0=A5=87=E0=A4=82=20=E0=A4=AE=E0=A5=88=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A5=8B=20=E0=A4=94=E0=A4=B0=20=E0=A4=AE?= =?UTF-8?q?=E0=A4=BE=E0=A4=87=E0=A4=95=E0=A5=8D=E0=A4=B0=E0=A5=8B=20?= =?UTF-8?q?=E0=A4=B5=E0=A4=BF=E0=A4=B6=E0=A5=8D=E0=A4=B2=E0=A5=87=E0=A4=B7?= =?UTF-8?q?=E0=A4=A3=20=E0=A4=AE=E0=A5=8B=E0=A4=A1=20=E0=A4=9C=E0=A5=8B?= =?UTF-8?q?=E0=A4=A1=E0=A4=BC=E0=A5=87=E0=A4=82=20ko:=20=ED=81=B4=EB=9F=AC?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=EB=A7=81=20=ED=8C=8C=EC=9D=B4=ED=94=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=EC=97=90=20=EA=B1=B0=EC=8B=9C=20=EB=B0=8F=20?= =?UTF-8?q?=EB=AF=B8=EC=8B=9C=20=EB=B6=84=EC=84=9D=20=EB=AA=A8=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20nl:=20Macro-=20en=20micro-analysemodi=20to?= =?UTF-8?q?evoegen=20aan=20de=20clustering-pipeline=20tr:=20K=C3=BCmeleme?= =?UTF-8?q?=20ard=C4=B1=C5=9F=C4=B1k=20d=C3=BCzenine=20makro=20ve=20mikro?= =?UTF-8?q?=20analiz=20modlar=C4=B1=20ekle=20pl:=20Dodanie=20tryb=C3=B3w?= =?UTF-8?q?=20analizy=20makro=20i=20mikro=20do=20potoku=20klasteryzacji=20?= =?UTF-8?q?tlh:=20clustering=20pipeline-vaD=20macro=20micro=20je=20nuch=20?= =?UTF-8?q?modes=20chel=20sim:=20Sul=20sul!=20Add=20macro=20micro=20analys?= =?UTF-8?q?is=20modes=20clustering=20pipeline=20zark=20min:=20Add=20macro?= =?UTF-8?q?=20micro=20analysis=20modes=20clustering=20pipeline!=20Ba-na-na?= =?UTF-8?q?=20nav:=20Add=20macro=20s=C3=AC=20micro=20analysis=20modes=20cl?= =?UTF-8?q?ustering=20pipeline=20fpi=20doth:=20Add=20macro=20ma=20micro=20?= =?UTF-8?q?analysis=20modes=20clustering=20pipeline=20mae=20sch:=20Schtrou?= =?UTF-8?q?mpfer=20des=20modes=20d'analyse=20macro=20et=20micro=20au=20sch?= =?UTF-8?q?troumpf=20de=20clustering=20tp:=20mi=20pana=20e=20nasin=20suli?= =?UTF-8?q?=20e=20nasin=20lili=20tawa=20pali=20kulupu=20eo:=20aldoni=20mak?= =?UTF-8?q?ro-=20kaj=20mikro-analizajn=20re=C4=9Dimojn=20al=20klasta=20duk?= =?UTF-8?q?to=20sil:=20i-u-e-o=20macro=20micro=20analysis=20modes=20cluste?= =?UTF-8?q?ring=20pipeline=20lch:=20laddition-lam=20des=20lodes-mom=20dana?= =?UTF-8?q?lyse-lam=20lacro-mem=20et=20licro-mem=20au=20lipeline-p=C3=A9m?= =?UTF-8?q?=20de=20lustering-cl=C3=A9m=20pig:=20add-way=20acro-may=20and-w?= =?UTF-8?q?ay=20icro-may=20nalysis-away=20odes-may=20o-tay=20ustering-clay?= =?UTF-8?q?=20ipeline-pay=20sin:=20Add=20macro=20ar=20micro=20analysis=20m?= =?UTF-8?q?odes=20clustering=20pipeline=20hut:=20Add=20macro=20micro=20ana?= =?UTF-8?q?lysis=20modes=20clustering=20pipeline=20moova=20sol:=20do-re-mi?= =?UTF-8?q?=20macro=20micro=20analysis=20modes=20clustering=20pipeline=20p?= =?UTF-8?q?ir:=20add=20macro=20micro=20analysis=20modes=20clustering=20pip?= =?UTF-8?q?eline=20hi-hi=20kab:=20rnu=20tarrayin=20n=20tmahilt=20n=20macro?= =?UTF-8?q?=20d=20micro=20i=20clustering=20mrs:=20Oh=20l'ami,=20j'ai=20raj?= =?UTF-8?q?out=C3=A9=20les=20modes=20macro=20et=20micro=20au=20pipeline,?= =?UTF-8?q?=20c'est=20carr=C3=A9=20pour=20l'analyse=20maintenant=20bre:=20?= =?UTF-8?q?ouzhpenna=C3=B1=20mod=20dizeska=C3=B1=20makro=20ha=20mikre=20d?= =?UTF-8?q?=E2=80=99ar=20san-stur=20klastara=C3=B1=20cor:=20aghjunghje=20i?= =?UTF-8?q?=20modi=20d'analisi=20macro=20=C3=A8=20micro=20=C3=A0=20u=20pip?= =?UTF-8?q?eline=20di=20clustering=20arr:=20Ahoy!=20Addin'=20macro=20and?= =?UTF-8?q?=20micro=20modes=20to=20the=20clustering=20map,=20keep=20an=20e?= =?UTF-8?q?ye=20on=20the=20horizon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- App/Controller/IaController.php | 233 ++++++++++++++ App/View/user/ia.php | 311 ++++++++++++++++--- App/routes.php | 4 + public/js/modules/iaViz.js | 525 ++++++++++++++++++++++++++++++++ scripts/clustering_pipeline.py | 175 +++++++++-- 5 files changed, 1184 insertions(+), 64 deletions(-) create mode 100644 public/js/modules/iaViz.js diff --git a/App/Controller/IaController.php b/App/Controller/IaController.php index cd438349..dd3719b9 100644 --- a/App/Controller/IaController.php +++ b/App/Controller/IaController.php @@ -124,6 +124,145 @@ public function debugPython(): void ]); } + /** + * 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, + a.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, + a.exercice_id, + a.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); + } + } + /** * API endpoint : POST /api/ia/clustering * PHP extrait les données de la BD, les passe au script Python via stdin. @@ -289,6 +428,100 @@ public function clustering(): void } } + /** + * 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. diff --git a/App/View/user/ia.php b/App/View/user/ia.php index c0fa09c3..d2da4f69 100644 --- a/App/View/user/ia.php +++ b/App/View/user/ia.php @@ -28,7 +28,7 @@ .ia-page .subtitle { color: #7f8c8d; margin-bottom: 2rem; font-size: 0.95rem; } /* Onglets principaux */ - .ia-tabs { display: flex; gap: 0; border-bottom: 2px solid #e8ecef; margin-bottom: 2rem; } + .ia-tabs { display: flex; gap: 0; border-bottom: 2px solid #e8ecef; margin-bottom: 2rem; flex-wrap: wrap; } .ia-tab-btn { background: transparent; border: none; padding: 0.85rem 1.5rem; cursor: pointer; color: #7f8c8d; font-size: 0.95rem; font-weight: 500; border-bottom: 3px solid transparent; @@ -60,7 +60,7 @@ .cluster-form { display: flex; gap: 1rem; flex-wrap: wrap; align-items: flex-end; margin-bottom: 1.5rem; } .form-field { display: flex; flex-direction: column; gap: 0.3rem; } .form-field label { font-size: 0.82rem; color: #7f8c8d; font-weight: 500; } - .form-field select, .form-field input { + .form-field select, .form-field input[type="number"] { padding: 0.5rem 0.75rem; border: 1px solid #dce1e7; border-radius: 6px; font-size: 0.9rem; background: #fff; min-width: 180px; } @@ -74,6 +74,13 @@ .btn-generate:hover { background: linear-gradient(135deg, #2980b9, #1f6da0); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(52,152,219,.3); } .btn-generate:disabled { background: #95a5a6; cursor: not-allowed; transform: none; box-shadow: none; } + .btn-back { + background: #ecf0f1; color: #2c3e50; border: none; padding: 0.5rem 1.2rem; + border-radius: 6px; cursor: pointer; font-size: 0.85rem; font-weight: 500; + transition: all .2s; display: inline-flex; align-items: center; gap: 0.4rem; margin-bottom: 1rem; + } + .btn-back:hover { background: #dce1e7; } + /* Zone résultat */ .cluster-result { display: none; margin-top: 1.5rem; } .cluster-result.visible { display: block; } @@ -94,6 +101,9 @@ } .chart-container img { width: 100%; height: auto; display: block; } + /* Plotly container */ + .plotly-container { min-height: 500px; } + .chart-meta { display: flex; gap: 1.5rem; padding: 1rem 1.5rem; background: #f8f9fa; border-top: 1px solid #e8ecef; flex-wrap: wrap; @@ -108,6 +118,17 @@ .error-box.visible { display: block; } .empty-msg { color: #95a5a6; font-style: italic; padding: 0.5rem 0; } + + /* Checkbox toggle trajectoires */ + .toggle-row { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; } + .toggle-row label { font-size: 0.88rem; color: #555; cursor: pointer; } + .toggle-row input[type="checkbox"] { width: 18px; height: 18px; cursor: pointer; accent-color: #3498db; } + + /* Info badge */ + .info-badge { + display: inline-block; background: #ebf5fb; color: #2980b9; padding: 0.35rem 0.8rem; + border-radius: 20px; font-size: 0.82rem; font-weight: 500; margin-bottom: 1rem; + } @@ -145,8 +166,10 @@
- - + + + +
-
+
+
+

🗺️ 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. +

+
+
+
+ +
-

Cartographie des codes

+

Cartographie des codes (image statique)

- Sélectionnez un exercice pour regrouper les tentatives des élèves par stratégie/erreur. - Le pipeline vectorise les séquences AES avec Doc2Vec, regroupe avec - K-Means, puis projette en 2D avec t-SNE. + Version originale : génère une image PNG du scatter plot via matplotlib.

@@ -280,17 +444,14 @@
-
Analyse en cours…
Entraînement Doc2Vec → K-Means → t-SNE (peut prendre 10-30 secondes)
-
-
Scatter plot t-SNE des clusters @@ -298,7 +459,6 @@
-
@@ -312,41 +472,117 @@
+ + + diff --git a/App/routes.php b/App/routes.php index 3797d549..9890c9f9 100644 --- a/App/routes.php +++ b/App/routes.php @@ -39,6 +39,10 @@ // IA API route (clustering pipeline) $router->post('/api/ia/clustering', App\Controller\IaController::class, 'clustering'); +// 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 diagnostic route (TEMPORAIRE - à supprimer après debug) $router->get('/api/ia/debug-python', App\Controller\IaController::class, 'debugPython'); diff --git a/public/js/modules/iaViz.js b/public/js/modules/iaViz.js new file mode 100644 index 00000000..c97d92dd --- /dev/null +++ b/public/js/modules/iaViz.js @@ -0,0 +1,525 @@ +/** + * 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). + */ + 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 = []; + + // --- 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: 8, + color: col, + opacity: 0.75, + line: { width: 1, color: '#fff' }, + symbol: clusterPts.map(p => p.correct ? 'circle' : 'x'), + }, + hoverinfo: 'text', + text: clusterPts.map(p => + `Étudiant: ${_esc(p.user_id)}
` + + `Cluster: ${p.cluster}
` + + `Correct: ${p.correct ? '✓ Oui' : '✗ Non'}
` + + `Date: ${p.date || '—'}
` + + `Tentative: #${p.attempt_id}` + ), + }); + } + + // --- 2) Trajectoires par étudiant (lignes directionnelles) --- + const showTrajectories = document.getElementById('microShowTrajectories')?.checked !== false; + if (showTrajectories) { + const trajectoryTraces = _buildTrajectoryTraces(points); + trajectoryTraces.forEach(t => traces.push(t)); + } + + const exName = data.exercise_name || ''; + const layout = { + title: { + text: `Vue Micro — ${exName}
${data.n_points} tentatives, ${nClusters} clusters`, + 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: 70, b: 50, l: 60, r: 30 }, + legend: { orientation: 'h', y: -0.18 }, + showlegend: true, + }; + + Plotly.newPlot(container, traces, layout, { responsive: true }); + + // Métadonnées + const metaEl = document.getElementById('microMeta'); + if (metaEl) { + const uniqueStudents = new Set(points.map(p => p.user_id)).size; + metaEl.innerHTML = + `${data.n_points} tentatives` + + `${nClusters} clusters` + + `${uniqueStudents} étudiants` + + `Exercice : ${_esc(exName)}`; + } + } + + /** + * Construit les traces Plotly pour les trajectoires étudiantes. + * Pour chaque user_id, trie les tentatives chronologiquement et + * trace des segments avec des flèches (annotations). + */ + function _buildTrajectoryTraces(points) { + const traces = []; + + // Regrouper par user_id + const byUser = {}; + points.forEach(p => { + if (!byUser[p.user_id]) byUser[p.user_id] = []; + byUser[p.user_id].push(p); + }); + + // Palette de couleurs pour les trajectoires (plus subtile) + const trajColors = [ + 'rgba(52,73,94,0.4)', 'rgba(142,68,173,0.4)', 'rgba(41,128,185,0.4)', + 'rgba(39,174,96,0.4)', 'rgba(243,156,18,0.4)', 'rgba(192,57,43,0.4)', + 'rgba(22,160,133,0.4)', 'rgba(127,140,141,0.4)', + ]; + + let colIdx = 0; + const annotations = []; + + Object.keys(byUser).forEach(userId => { + let userPts = byUser[userId]; + if (userPts.length < 2) return; // Pas de trajectoire pour un seul point + + // Trier par date puis par attempt_id + 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 col = trajColors[colIdx % trajColors.length]; + colIdx++; + + // Trace ligne + traces.push({ + x: userPts.map(p => p.x), + y: userPts.map(p => p.y), + mode: 'lines', + type: 'scatter', + name: `Traj. ${userId}`, + line: { + color: col, + width: 1.5, + dash: 'dot', + }, + hoverinfo: 'skip', + showlegend: false, + }); + + // Flèches (annotations Plotly) du point N-1 vers N + for (let i = 0; i < userPts.length - 1; i++) { + const fromPt = userPts[i]; + const toPt = userPts[i + 1]; + // N'ajouter des flèches que si les points sont suffisamment éloignés + const dx = toPt.x - fromPt.x; + const dy = toPt.y - fromPt.y; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < 0.5) continue; // sauter les points très proches + + annotations.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.2, + arrowwidth: 1.5, + arrowcolor: col.replace('0.4', '0.6'), + standoff: 4, + startstandoff: 4, + }); + } + }); + + // Stocker les annotations dans un attribut spécial pour les appliquer au layout + if (annotations.length > 0) { + // On va les injecter via relayout après le rendu + setTimeout(() => { + const container = document.getElementById('microPlot'); + if (container && window.Plotly) { + // Limiter les flèches si trop nombreuses pour la perf + const maxAnnotations = 200; + const annots = annotations.length > maxAnnotations + ? annotations.slice(0, maxAnnotations) + : annotations; + Plotly.relayout(container, { annotations: annots }); + } + }, 100); + } + + return traces; + } + + /** + * 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/scripts/clustering_pipeline.py b/scripts/clustering_pipeline.py index 58b14e53..140879a3 100644 --- a/scripts/clustering_pipeline.py +++ b/scripts/clustering_pipeline.py @@ -4,19 +4,14 @@ clustering_pipeline.py Pipeline Data Science : Doc2Vec -> KMeans -> t-SNE -> scatter plot base64 -Deux modes d'utilisation : +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 : -{ - "success": true, - "image_base64": "data:image/png;base64,...", - "n_points": 123, - "clusters": [0,1,2,...], - "students": ["stu1","stu2",...], - "exercise_name": "exo_foo" -} +Renvoie un JSON sur stdout. """ import sys @@ -37,14 +32,15 @@ os.makedirs(os.path.join(SCRIPT_DIR, 'utils'), exist_ok=True) -# ── Pipeline principal (indépendant de la source de données) ───────────────── -def run_pipeline(data, n_clusters=8, perplexity=30, exercise_id=None): - """Exécute le pipeline complet à partir d'une liste de dicts et renvoie le résultat.""" +# ── 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 pour cet exercice ({len(data)} trouvées, minimum 5)." + '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}') @@ -54,10 +50,7 @@ def run_pipeline(data, n_clusters=8, perplexity=30, exercise_id=None): if not att.get('eval_set'): att['eval_set'] = 'training' - # Copie "training" pour l'entraînement train_data = [dict(att, eval_set='training') for att in data] - - # Copie "test" pour l'inférence infer_data = [dict(att, eval_set='test') for att in data] # 2) Doc2Vec : entraînement + inférence @@ -110,15 +103,28 @@ def run_pipeline(data, n_clusters=8, perplexity=30, exercise_id=None): tsne = TSNE(n_components=2, perplexity=actual_perplexity, random_state=42) coords_2d = tsne.fit_transform(vectors) - # 5) Génération du scatter plot matplotlib → base64 + # 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( @@ -148,12 +154,12 @@ def run_pipeline(data, n_clusters=8, perplexity=30, exercise_id=None): img_base64 = 'data:image/png;base64,' + base64.b64encode(buf.read()).decode('utf-8') buf.close() - # 6) Métadonnées - students = [att.get('user_id', '?') for att in data] + 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, @@ -161,19 +167,140 @@ def run_pipeline(data, n_clusters=8, perplexity=30, exercise_id=None): '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 + import numpy as np + + actual_perplexity = min(perplexity, max(1, len(vectors) - 1)) + tsne = TSNE(n_components=2, perplexity=actual_perplexity, random_state=42) + coords_2d = tsne.fit_transform(vectors) + + # 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, } +# ── Ancien pipeline (rétro-compatibilité) ──────────────────────────────────── +def run_pipeline(data, n_clusters=8, perplexity=30, exercise_id=None): + """Rétro-compatibilité : appelle run_pipeline_micro.""" + return run_pipeline_micro(data, n_clusters, perplexity, exercise_id) + + # ── Lecture depuis stdin (mode appelé par PHP) ─────────────────────────────── def run_from_stdin(): - """Lit le JSON depuis stdin et lance le pipeline.""" + """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') - return run_pipeline(data, n_clusters, perplexity, 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) ───────────────────────────────── @@ -183,7 +310,7 @@ def run_from_db(exercise_id, n_clusters=8, perplexity=30): conn = _get_connection(env) data = _load_attempts(conn, exercise_id) conn.close() - return run_pipeline(data, n_clusters, perplexity, exercise_id) + return run_pipeline_micro(data, n_clusters, perplexity, exercise_id) def _load_env(): From ddab364d8e93a0c899bbcf4c165f3f3cff10b151 Mon Sep 17 00:00:00 2001 From: PibouleauJB Date: Tue, 10 Mar 2026 11:13:44 +0100 Subject: [PATCH 16/29] Fix #1 --- App/Controller/IaController.php | 50 ++++++++++++++++----------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/App/Controller/IaController.php b/App/Controller/IaController.php index dd3719b9..a2b83570 100644 --- a/App/Controller/IaController.php +++ b/App/Controller/IaController.php @@ -31,8 +31,8 @@ public function index(): void // 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(); + $totalExercises = (int)$pdo->query("SELECT COUNT(*) FROM exercises")->fetchColumn(); + $totalStudents = (int)$pdo->query("SELECT COUNT(DISTINCT student_id) FROM attempts")->fetchColumn(); // Répartition par eval_set $evalSets = $pdo->query( @@ -41,18 +41,18 @@ public function index(): void // Ressources $resources = $pdo->query( - "SELECT ressource_id, ressource_name FROM ressources ORDER BY ressource_name ASC" + "SELECT resource_id, resource_name FROM resources ORDER BY resource_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, e.ressource_id, r.ressource_name, + "SELECT e.exercise_id, e.exo_name AS exercise_name, e.resource_id, r.resource_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" + FROM exercises e + LEFT JOIN resources r ON e.resource_id = r.resource_id + LEFT JOIN attempts a ON a.exercise_id = e.exercise_id AND a.aes2 IS NOT NULL AND a.aes2 != '' + GROUP BY e.exercise_id + ORDER BY r.resource_name ASC, e.exo_name ASC" )->fetchAll(\PDO::FETCH_ASSOC); $this->renderView('user/ia', [ @@ -154,17 +154,17 @@ public function macro(): void a.aes2, a.eval_set, a.correct, - a.user_id, - a.exercice_id, - e.exercice_name AS exercise_name + a.student_id AS user_id, + a.exercise_id AS exercice_id, + e.exo_name AS exercise_name FROM attempts a - JOIN exercices e ON a.exercice_id = e.exercice_id + JOIN exercises e ON a.exercise_id = e.exercise_id WHERE a.aes2 IS NOT NULL AND a.aes2 != '' "; $params = []; if ($resourceId) { - $sql .= " AND e.ressource_id = :rid"; + $sql .= " AND e.resource_id = :rid"; $params['rid'] = $resourceId; } @@ -228,16 +228,16 @@ public function micro(): void a.aes2, a.eval_set, a.correct, - a.user_id, - a.exercice_id, + a.student_id AS user_id, + a.exercise_id AS exercice_id, a.submission_date, - e.exercice_name AS exercise_name + e.exo_name AS exercise_name FROM attempts a - JOIN exercices e ON a.exercice_id = e.exercice_id - WHERE a.exercice_id = :eid + JOIN exercises e ON a.exercise_id = e.exercise_id + WHERE a.exercise_id = :eid AND a.aes2 IS NOT NULL AND a.aes2 != '' - ORDER BY a.user_id, a.attempt_id + ORDER BY a.student_id, a.attempt_id "); $stmt->execute(['eid' => $exerciseId]); $attempts = $stmt->fetchAll(\PDO::FETCH_ASSOC); @@ -302,12 +302,12 @@ public function clustering(): void a.aes2, a.eval_set, a.correct, - a.user_id, - a.exercice_id, - e.exercice_name AS exercise_name + a.student_id AS user_id, + a.exercise_id AS exercice_id, + e.exo_name AS exercise_name FROM attempts a - JOIN exercices e ON a.exercice_id = e.exercice_id - WHERE a.exercice_id = :eid + JOIN exercises e ON a.exercise_id = e.exercise_id + WHERE a.exercise_id = :eid AND a.aes2 IS NOT NULL AND a.aes2 != '' ORDER BY a.attempt_id From d36fa2812ece161645cca23437a2322b3556e33a Mon Sep 17 00:00:00 2001 From: PibouleauJB Date: Tue, 10 Mar 2026 11:21:20 +0100 Subject: [PATCH 17/29] Fix #1b --- App/Controller/IaController.php | 50 ++++++++++++++++----------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/App/Controller/IaController.php b/App/Controller/IaController.php index a2b83570..e296cae5 100644 --- a/App/Controller/IaController.php +++ b/App/Controller/IaController.php @@ -31,8 +31,8 @@ public function index(): void // Stats globales $totalAttempts = (int)$pdo->query("SELECT COUNT(*) FROM attempts")->fetchColumn(); - $totalExercises = (int)$pdo->query("SELECT COUNT(*) FROM exercises")->fetchColumn(); - $totalStudents = (int)$pdo->query("SELECT COUNT(DISTINCT student_id) 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( @@ -41,18 +41,18 @@ public function index(): void // Ressources $resources = $pdo->query( - "SELECT resource_id, resource_name FROM resources ORDER BY resource_name ASC" + "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.exercise_id, e.exo_name AS exercise_name, e.resource_id, r.resource_name, + "SELECT e.exercice_id, e.exercice_name AS exercise_name, e.ressource_id, r.ressource_name, COUNT(a.attempt_id) AS nb_attempts - FROM exercises e - LEFT JOIN resources r ON e.resource_id = r.resource_id - LEFT JOIN attempts a ON a.exercise_id = e.exercise_id AND a.aes2 IS NOT NULL AND a.aes2 != '' - GROUP BY e.exercise_id - ORDER BY r.resource_name ASC, e.exo_name ASC" + 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', [ @@ -154,17 +154,17 @@ public function macro(): void a.aes2, a.eval_set, a.correct, - a.student_id AS user_id, - a.exercise_id AS exercice_id, - e.exo_name AS exercise_name + a.user_id AS user_id, + a.exercice_id AS exercice_id, + e.exercice_name AS exercise_name FROM attempts a - JOIN exercises e ON a.exercise_id = e.exercise_id + 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.resource_id = :rid"; + $sql .= " AND e.ressource_id = :rid"; $params['rid'] = $resourceId; } @@ -228,16 +228,16 @@ public function micro(): void a.aes2, a.eval_set, a.correct, - a.student_id AS user_id, - a.exercise_id AS exercice_id, + a.user_id AS user_id, + a.exercice_id AS exercice_id, a.submission_date, - e.exo_name AS exercise_name + e.exercice_name AS exercise_name FROM attempts a - JOIN exercises e ON a.exercise_id = e.exercise_id - WHERE a.exercise_id = :eid + 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.student_id, a.attempt_id + ORDER BY a.user_id, a.attempt_id "); $stmt->execute(['eid' => $exerciseId]); $attempts = $stmt->fetchAll(\PDO::FETCH_ASSOC); @@ -302,12 +302,12 @@ public function clustering(): void a.aes2, a.eval_set, a.correct, - a.student_id AS user_id, - a.exercise_id AS exercice_id, - e.exo_name AS exercise_name + a.user_id AS user_id, + a.exercice_id AS exercice_id, + e.exercice_name AS exercise_name FROM attempts a - JOIN exercises e ON a.exercise_id = e.exercise_id - WHERE a.exercise_id = :eid + 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.attempt_id From 296470571e231a10e71d13d83a865ed6b02126e3 Mon Sep 17 00:00:00 2001 From: PibouleauJB Date: Tue, 10 Mar 2026 11:32:49 +0100 Subject: [PATCH 18/29] Fix #1c --- App/Controller/IaController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/App/Controller/IaController.php b/App/Controller/IaController.php index e296cae5..67ef9851 100644 --- a/App/Controller/IaController.php +++ b/App/Controller/IaController.php @@ -230,7 +230,7 @@ public function micro(): void a.correct, a.user_id AS user_id, a.exercice_id AS exercice_id, - a.submission_date, + NULL AS submission_date, e.exercice_name AS exercise_name FROM attempts a JOIN exercices e ON a.exercice_id = e.exercice_id From e127d25dfd0c99c17828be06915fe3bf072c1211 Mon Sep 17 00:00:00 2001 From: PibouleauJB Date: Tue, 10 Mar 2026 19:07:47 +0100 Subject: [PATCH 19/29] =?UTF-8?q?fr:=20Am=C3=A9lioration=20de=20la=20norma?= =?UTF-8?q?lisation=20et=20des=20param=C3=A8tres=20t-SNE=20dans=20le=20pip?= =?UTF-8?q?eline=20de=20clustering=20;=20ajout=20de=20la=20gestion=20des?= =?UTF-8?q?=20trajectoires=20avec=20surbrillance=20dans=20la=20vue=20Micro?= =?UTF-8?q?=20en:=20Improve=20normalization=20and=20t-SNE=20parameters=20i?= =?UTF-8?q?n=20clustering=20pipeline;=20add=20trajectory=20management=20wi?= =?UTF-8?q?th=20highlighting=20in=20Micro=20view=20es:=20Mejora=20de=20la?= =?UTF-8?q?=20normalizaci=C3=B3n=20y=20par=C3=A1metros=20t-SNE=20en=20el?= =?UTF-8?q?=20pipeline=20de=20clustering;=20adici=C3=B3n=20de=20gesti?= =?UTF-8?q?=C3=B3n=20de=20trayectorias=20con=20resaltado=20en=20vista=20Mi?= =?UTF-8?q?cro=20de:=20Verbesserung=20der=20Normalisierung=20und=20t-SNE-P?= =?UTF-8?q?arameter=20in=20der=20Clustering-Pipeline;=20Hinzuf=C3=BCgen=20?= =?UTF-8?q?von=20Trajektorienmanagement=20mit=20Hervorhebung=20in=20der=20?= =?UTF-8?q?Micro-Ansicht=20it:=20Miglioramento=20della=20normalizzazione?= =?UTF-8?q?=20e=20dei=20parametri=20t-SNE=20nella=20pipeline=20di=20cluste?= =?UTF-8?q?ring;=20aggiunta=20della=20gestione=20delle=20traiettorie=20con?= =?UTF-8?q?=20evidenziazione=20nella=20vista=20Micro=20zh:=20=E6=94=B9?= =?UTF-8?q?=E8=BF=9B=E8=81=9A=E7=B1=BB=E6=B5=81=E6=B0=B4=E7=BA=BF=E4=B8=AD?= =?UTF-8?q?=E7=9A=84=E5=BD=92=E4=B8=80=E5=8C=96=E5=92=8C=20t-SNE=20?= =?UTF-8?q?=E5=8F=82=E6=95=B0=EF=BC=9B=E5=9C=A8=E5=BE=AE=E8=A7=82=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=E4=B8=AD=E5=A2=9E=E5=8A=A0=E5=B8=A6=E6=9C=89=E9=AB=98?= =?UTF-8?q?=E4=BA=AE=E6=98=BE=E7=A4=BA=E7=9A=84=E8=BD=A8=E8=BF=B9=E7=AE=A1?= =?UTF-8?q?=E7=90=86=20ja:=20=E3=82=AF=E3=83=A9=E3=82=B9=E3=82=BF=E3=83=AA?= =?UTF-8?q?=E3=83=B3=E3=82=B0=E3=83=91=E3=82=A4=E3=83=97=E3=83=A9=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=81=AB=E3=81=8A=E3=81=91=E3=82=8B=E6=AD=A3=E8=A6=8F?= =?UTF-8?q?=E5=8C=96=E3=81=A8=20t-SNE=20=E3=83=91=E3=83=A9=E3=83=A1?= =?UTF-8?q?=E3=83=BC=E3=82=BF=E3=81=AE=E6=94=B9=E5=96=84=E3=80=82Micro=20?= =?UTF-8?q?=E3=83=93=E3=83=A5=E3=83=BC=E3=81=B8=E3=81=AE=E3=83=8F=E3=82=A4?= =?UTF-8?q?=E3=83=A9=E3=82=A4=E3=83=88=E4=BB=98=E3=81=8D=E8=BB=8C=E9=81=93?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E3=81=AE=E8=BF=BD=E5=8A=A0=20ru:=20=D0=A3?= =?UTF-8?q?=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=B0=D1=80=D0=B0=D0=BC=D0=B5=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=20t-SNE=20=D0=B2=20=D0=BA=D0=BE=D0=BD=D0=B2=D0=B5=D0=B9?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=20=D0=BA=D0=BB=D0=B0=D1=81=D1=82=D0=B5=D1=80?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8;=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=83=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=BE=D1=80=D0=B8=D1=8F=D0=BC=D0=B8=20=D1=81?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B4=D1=81=D0=B2=D0=B5=D1=82=D0=BA=D0=BE=D0=B9?= =?UTF-8?q?=20=D0=B2=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC=D0=B5=20Micro=20ar:?= =?UTF-8?q?=20=D8=AA=D8=AD=D8=B3=D9=8A=D9=86=20=D8=A7=D9=84=D8=AA=D8=B7?= =?UTF-8?q?=D8=A8=D9=8A=D8=B9=20=D9=88=D9=85=D8=B9=D9=84=D9=85=D8=A7=D8=AA?= =?UTF-8?q?=20t-SNE=20=D9=81=D9=8A=20=D8=AE=D8=B7=20=D8=A3=D9=86=D8=A7?= =?UTF-8?q?=D8=A8=D9=8A=D8=A8=20=D8=A7=D9=84=D8=AA=D8=AC=D9=85=D9=8A=D8=B9?= =?UTF-8?q?=D8=9B=20=D8=A5=D8=B6=D8=A7=D9=81=D8=A9=20=D8=A5=D8=AF=D8=A7?= =?UTF-8?q?=D8=B1=D8=A9=20=D8=A7=D9=84=D9=85=D8=B3=D8=A7=D8=B1=D8=A7=D8=AA?= =?UTF-8?q?=20=D9=85=D8=B9=20=D8=A7=D9=84=D8=AA=D9=85=D9=8A=D9=8A=D8=B2=20?= =?UTF-8?q?=D9=81=D9=8A=20=D8=B9=D8=B1=D8=B6=20Micro=20pt:=20Melhoria=20da?= =?UTF-8?q?=20normaliza=C3=A7=C3=A3o=20e=20par=C3=A2metros=20t-SNE=20no=20?= =?UTF-8?q?pipeline=20de=20clustering;=20adi=C3=A7=C3=A3o=20de=20gest?= =?UTF-8?q?=C3=A3o=20de=20trajet=C3=B3rias=20com=20destaque=20na=20vista?= =?UTF-8?q?=20Micro=20hi:=20=E0=A4=95=E0=A5=8D=E0=A4=B2=E0=A4=B8=E0=A5=8D?= =?UTF-8?q?=E0=A4=9F=E0=A4=B0ing=20=E0=A4=AA=E0=A4=BE=E0=A4=87=E0=A4=AA?= =?UTF-8?q?=E0=A4=B2=E0=A4=BE=E0=A4=87=E0=A4=A8=20=E0=A4=AE=E0=A5=87?= =?UTF-8?q?=E0=A4=82=20=E0=A4=B8=E0=A4=BE=E0=A4=AE=E0=A4=BE=E0=A4=A8?= =?UTF-8?q?=E0=A5=8D=E0=A4=AF=E0=A5=80=E0=A4=95=E0=A4=B0=E0=A4=A3=20?= =?UTF-8?q?=E0=A4=94=E0=A4=B0=20t-SNE=20=E0=A4=AE=E0=A4=BE=E0=A4=AA?= =?UTF-8?q?=E0=A4=A6=E0=A4=82=E0=A4=A1=E0=A5=8B=E0=A4=82=20=E0=A4=AE?= =?UTF-8?q?=E0=A5=87=E0=A4=82=20=E0=A4=B8=E0=A5=81=E0=A4=A7=E0=A4=BE?= =?UTF-8?q?=E0=A4=B0;=20=E0=A4=AE=E0=A4=BE=E0=A4=87=E0=A4=95=E0=A5=8D?= =?UTF-8?q?=E0=A4=B0=E0=A5=8B=20=E0=A4=B5=E0=A5=8D=E0=A4=AF=E0=A5=82=20?= =?UTF-8?q?=E0=A4=AE=E0=A5=87=E0=A4=82=20=E0=A4=B9=E0=A4=BE=E0=A4=87?= =?UTF-8?q?=E0=A4=B2=E0=A4=BE=E0=A4=87=E0=A4=9F=E0=A4=BF=E0=A4=82=E0=A4=97?= =?UTF-8?q?=20=E0=A4=95=E0=A5=87=20=E0=A4=B8=E0=A4=BE=E0=A4=A5=20=E0=A4=AA?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=95=E0=A5=8D=E0=A4=B7=E0=A5=87=E0=A4=AA?= =?UTF-8?q?=E0=A4=B5=E0=A4=95=E0=A5=8D=E0=A4=B0=20=E0=A4=AA=E0=A5=8D?= =?UTF-8?q?=E0=A4=B0=E0=A4=AC=E0=A4=82=E0=A4=A7=E0=A4=A8=20=E0=A4=9C?= =?UTF-8?q?=E0=A5=8B=E0=A4=A1=E0=A4=BC=E0=A4=BE=20=E0=A4=97=E0=A4=AF?= =?UTF-8?q?=E0=A4=BE=20ko:=20=ED=81=B4=EB=9F=AC=EC=8A=A4=ED=84=B0=EB=A7=81?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=EC=9D=98=20?= =?UTF-8?q?=EC=A0=95=EA=B7=9C=ED=99=94=20=EB=B0=8F=20t-SNE=20=ED=8C=8C?= =?UTF-8?q?=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EA=B0=9C=EC=84=A0;=20Micro=20?= =?UTF-8?q?=EB=B7=B0=EC=97=90=20=ED=95=98=EC=9D=B4=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EA=B8=B0=EB=8A=A5=EC=9D=B4=20=ED=8F=AC=ED=95=A8?= =?UTF-8?q?=EB=90=9C=20=EA=B6=A4=EC=A0=81=20=EA=B4=80=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20nl:=20Verbetering=20van=20normalisatie=20en=20t-SNE?= =?UTF-8?q?-parameters=20in=20de=20clustering-pipeline;=20toevoeging=20van?= =?UTF-8?q?=20trajectbeheer=20met=20markering=20in=20Micro-weergave=20tr:?= =?UTF-8?q?=20K=C3=BCmeleme=20ard=C4=B1=C5=9F=C4=B1k=20d=C3=BCzeninde=20no?= =?UTF-8?q?rmalle=C5=9Ftirme=20ve=20t-SNE=20parametrelerinin=20iyile=C5=9F?= =?UTF-8?q?tirilmesi;=20Micro=20g=C3=B6r=C3=BCn=C3=BCm=C3=BCnde=20vurgulam?= =?UTF-8?q?a=20ile=20y=C3=B6r=C3=BCnge=20y=C3=B6netimi=20eklendi=20pl:=20P?= =?UTF-8?q?oprawa=20normalizacji=20i=20parametr=C3=B3w=20t-SNE=20w=20potok?= =?UTF-8?q?u=20klasteryzacji;=20dodanie=20zarz=C4=85dzania=20trajektoriami?= =?UTF-8?q?=20z=20wyr=C3=B3=C5=BCnieniem=20w=20widoku=20Micro=20tlh:=20clu?= =?UTF-8?q?stering=20pipeline-Daq=20normalization=20t-SNE=20parameters=20j?= =?UTF-8?q?e=20choH;=20Micro=20leghmeH=20mIw=20chu'=20chel=20sim:=20Sul=20?= =?UTF-8?q?sul!=20Improve=20normalization=20t-SNE=20clustering=20pipeline,?= =?UTF-8?q?=20add=20trajectory=20highlighting=20Micro=20view=20zark=20min:?= =?UTF-8?q?=20Improve=20normalization=20t-SNE=20parameters=20clustering=20?= =?UTF-8?q?pipeline!=20Add=20trajectory=20highlighting=20Micro=20view!=20B?= =?UTF-8?q?a-na-na=20nav:=20Improve=20normalization=20s=C3=AC=20t-SNE=20pa?= =?UTF-8?q?rameters=20clustering=20pipeline;=20add=20trajectory=20manageme?= =?UTF-8?q?nt=20hu=20highlighting=20Micro=20view=20doth:=20Improve=20norma?= =?UTF-8?q?lization=20ma=20t-SNE=20parameters=20clustering=20pipeline;=20a?= =?UTF-8?q?dd=20trajectory=20management=20highlighting=20Micro=20view=20ma?= =?UTF-8?q?e=20sch:=20Schtroumpfer=20la=20normalisation=20et=20les=20param?= =?UTF-8?q?=C3=A8tres=20t-SNE=20dans=20le=20schtroumpf=20de=20clustering;?= =?UTF-8?q?=20schtroumpfer=20les=20trajectoires=20dans=20la=20vue=20Micro?= =?UTF-8?q?=20tp:=20mi=20pona=20e=20nanpa=20e=20t-SNE=20tawa=20pali=20kulu?= =?UTF-8?q?pu.=20mi=20pana=20e=20nasin=20tawa=20lukin=20lili=20eo:=20plibo?= =?UTF-8?q?nigi=20normaligon=20kaj=20t-SNE-parametrojn=20en=20klasta=20duk?= =?UTF-8?q?to;=20aldoni=20trajektorian=20mastrumadon=20kun=20reliefigo=20e?= =?UTF-8?q?n=20Mikro-vido=20sil:=20i-u-e-o=20normalization=20t-SNE=20param?= =?UTF-8?q?eters=20clustering=20pipeline=20trajectory=20Micro=20view=20lch?= =?UTF-8?q?:=20lam=C3=A9lioration-lam=20de=20la=20lormalisation-nom=20et?= =?UTF-8?q?=20des=20laram=C3=A8tres-pam=20t-SNE=20dans=20le=20lipeline-p?= =?UTF-8?q?=C3=A9m=20de=20lustering-cl=C3=A9m;=20laddition-lam=20de=20la?= =?UTF-8?q?=20lestion-gem=20des=20lrajectoires-trom=20avec=20lurbrillance-?= =?UTF-8?q?som=20dans=20la=20lue-vum=20Micro=20pig:=20improve-way=20ormali?= =?UTF-8?q?zation-nay=20and-way=20t-SNE=20arameters-pay=20in-way=20usterin?= =?UTF-8?q?g-clay=20ipeline-pay;=20add-way=20rajectory-tay=20anagement-may?= =?UTF-8?q?=20ith-way=20ighlighting-hay=20in-way=20icro-may=20iew-vay=20si?= =?UTF-8?q?n:=20Improve=20normalization=20ar=20t-SNE=20parameters=20cluste?= =?UTF-8?q?ring=20pipeline;=20add=20trajectory=20highlighting=20Micro=20vi?= =?UTF-8?q?ew=20hut:=20Improve=20normalization=20t-SNE=20parameters=20clus?= =?UTF-8?q?tering=20pipeline,=20add=20trajectory=20highlighting=20Micro=20?= =?UTF-8?q?view=20moova=20sol:=20do-re-mi=20improve=20normalization=20t-SN?= =?UTF-8?q?E=20parameters=20trajectory=20Micro=20view=20pir:=20improve=20n?= =?UTF-8?q?ormalization=20t-SNE=20parameters=20clustering=20pipeline=20add?= =?UTF-8?q?=20trajectory=20highlight=20Micro=20view=20hi-hi=20kab:=20asemm?= =?UTF-8?q?et=20n=20normalization=20d=20t-SNE=20parameters=20deg=20tmahilt?= =?UTF-8?q?=20n=20clustering;=20rnu=20trajectory=20management=20s=20highli?= =?UTF-8?q?ghting=20deg=20Micro=20view=20mrs:=20Oh=20l'ami,=20j'ai=20recal?= =?UTF-8?q?=C3=A9=20la=20normalisation=20et=20les=20r=C3=A9glages=20t-SNE?= =?UTF-8?q?=20dans=20le=20pipeline;=20j'ai=20aussi=20balanc=C3=A9=20la=20g?= =?UTF-8?q?estion=20des=20trajectoires=20qui=20flashent=20dans=20la=20vue?= =?UTF-8?q?=20Micro,=20c'est=20le=20feu=20bre:=20gwellaat=20an=20norsaat?= =?UTF-8?q?=20hag=20an=20arventenno=C3=B9=20t-SNE=20er=20san-stur=20klasta?= =?UTF-8?q?ra=C3=B1;=20ouzhpenna=C3=B1=20ar=20mera=C3=B1=20trajektorio?= =?UTF-8?q?=C3=B9=20gant=20ussklerijenna=C3=B1=20er=20gwel=20Micro=20cor:?= =?UTF-8?q?=20amigliuramentu=20di=20a=20nurmalisazione=20=C3=A8=20di=20i?= =?UTF-8?q?=20parametri=20t-SNE=20in=20u=20pipeline=20di=20clustering;=20a?= =?UTF-8?q?ghjunta=20di=20a=20gestione=20di=20e=20traiettorie=20c=C3=B9=20?= =?UTF-8?q?risaltu=20in=20a=20vista=20Micro=20arr:=20Ahoy!=20Betterin'=20t?= =?UTF-8?q?he=20normalization=20and=20t-SNE=20charts=20in=20the=20clusteri?= =?UTF-8?q?ng=20map;=20addin'=20trackin'=20paths=20with=20glow=20in=20the?= =?UTF-8?q?=20Micro=20lookout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/js/modules/iaViz.js | 361 +++++++++++++++++++++++---------- scripts/clustering_pipeline.py | 40 +++- 2 files changed, 284 insertions(+), 117 deletions(-) diff --git a/public/js/modules/iaViz.js b/public/js/modules/iaViz.js index c97d92dd..d5960740 100644 --- a/public/js/modules/iaViz.js +++ b/public/js/modules/iaViz.js @@ -280,7 +280,7 @@ const IaViz = (function () { } /** - * Rendu Plotly de la vue Micro (clusters + trajectoires). + * Rendu Plotly de la vue Micro (clusters + trajectoires + hover focus). */ function _renderMicro(data) { const container = document.getElementById('microPlot'); @@ -292,12 +292,24 @@ const IaViz = (function () { const nClusters = data.n_clusters || 8; const traces = []; + // ── Opacités par défaut (tout est grisé) ── + const DIM_OPACITY = 0.15; + const BRIGHT_OPACITY = 1.0; + const DIM_LINE_WIDTH = 1; + const BRIGHT_LINE_WIDTH = 3; + + // ── Préparer le lookup user_id → indices de traces ── + // On indexe chaque point avec son user_id pour le hover focus + const allUserIds = [...new Set(points.map(p => p.user_id))]; + // --- 1) Points colorés par cluster --- + // On crée UNE trace par cluster, chaque point a son symbole selon correct 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), @@ -305,34 +317,126 @@ const IaViz = (function () { type: 'scatter', name: `Cluster ${c}`, marker: { - size: 8, - color: col, - opacity: 0.75, - line: { width: 1, color: '#fff' }, - symbol: clusterPts.map(p => p.correct ? 'circle' : 'x'), + size: clusterPts.map(p => p.correct ? 14 : 8), + color: clusterPts.map(p => p.correct ? col : 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 => - `Étudiant: ${_esc(p.user_id)}
` + - `Cluster: ${p.cluster}
` + - `Correct: ${p.correct ? '✓ Oui' : '✗ Non'}
` + - `Date: ${p.date || '—'}
` + - `Tentative: #${p.attempt_id}` - ), + 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: 12, family: 'sans-serif' }, + }, }); } - // --- 2) Trajectoires par étudiant (lignes directionnelles) --- + // Nombre de traces de clusters (pour identifier les traces trajectoires ensuite) + const nClusterTraces = traces.length; + + // --- 2) Trajectoires par étudiant (lignes avec flèches) --- const showTrajectories = document.getElementById('microShowTrajectories')?.checked !== false; + const trajectoryAnnotations = []; + if (showTrajectories) { - const trajectoryTraces = _buildTrajectoryTraces(points); - trajectoryTraces.forEach(t => traces.push(t)); + 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}', 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: DIM_LINE_WIDTH, + dash: 'dot', + }, + hoverinfo: 'skip', + showlegend: false, + customdata: userPts.map(() => userId), + _userId: userId, + _colTemplate: colTemplate, + }); + + // Flèches (annotations) du point N-1 vers N + 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: 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: `Vue Micro — ${exName}
${data.n_points} tentatives, ${nClusters} clusters`, + text: `Vue Micro — ${exName}
${data.n_points} tentatives, ${nClusters} clusters · Survolez un point pour isoler la trajectoire`, font: { size: 15, color: '#2c3e50' }, }, xaxis: { title: 't-SNE dim. 1', zeroline: false, showgrid: true, gridcolor: '#ecf0f1' }, @@ -340,126 +444,164 @@ const IaViz = (function () { hovermode: 'closest', plot_bgcolor: '#fafbfc', paper_bgcolor: '#fff', - margin: { t: 70, b: 50, l: 60, r: 30 }, + 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(container, traces, layout, { responsive: true }); + Plotly.newPlot(container, traces, layout, { responsive: true }).then(() => { + // ── Hover Focus : surbrillance de la trajectoire de l'utilisateur survolé ── + 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(exName)}`; } } /** - * Construit les traces Plotly pour les trajectoires étudiantes. - * Pour chaque user_id, trie les tentatives chronologiquement et - * trace des segments avec des flèches (annotations). + * Met en surbrillance la trajectoire d'un utilisateur donné. */ - function _buildTrajectoryTraces(points) { - const traces = []; - - // Regrouper par user_id - const byUser = {}; - points.forEach(p => { - if (!byUser[p.user_id]) byUser[p.user_id] = []; - byUser[p.user_id].push(p); - }); - - // Palette de couleurs pour les trajectoires (plus subtile) - const trajColors = [ - 'rgba(52,73,94,0.4)', 'rgba(142,68,173,0.4)', 'rgba(41,128,185,0.4)', - 'rgba(39,174,96,0.4)', 'rgba(243,156,18,0.4)', 'rgba(192,57,43,0.4)', - 'rgba(22,160,133,0.4)', 'rgba(127,140,141,0.4)', - ]; - - let colIdx = 0; - const annotations = []; - - Object.keys(byUser).forEach(userId => { - let userPts = byUser[userId]; - if (userPts.length < 2) return; // Pas de trajectoire pour un seul point - - // Trier par date puis par attempt_id - 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); - }); + function _highlightUser(container, traces, nClusterTraces, userId, annotations, layout) { + const DIM_OPACITY = 0.08; + const BRIGHT_OPACITY = 1.0; + + const update = {}; + + // Mise à jour des traces de clusters (points) + for (let i = 0; i < nClusterTraces; i++) { + const trace = traces[i]; + if (!trace.customdata) continue; + const opacities = trace.customdata.map(uid => uid === userId ? BRIGHT_OPACITY : DIM_OPACITY); + const sizes = []; + // Recalculer les tailles : en surbrillance les points sont plus gros + 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'; + if (isHovered) { + sizes.push(isStar ? 18 : 11); + } else { + sizes.push(isStar ? 14 : 8); + } + } + } + update['marker.opacity'] = opacities; + if (sizes.length > 0) update['marker.size'] = sizes; + Plotly.restyle(container, update, [i]); + } - const col = trajColors[colIdx % trajColors.length]; - colIdx++; + // Mise à jour des traces de 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.9') + : trace._colTemplate.replace('{a}', String(DIM_OPACITY)), + 'line.width': isHighlighted ? 3 : 0.5, + 'line.dash': isHighlighted ? 'solid' : 'dot', + }, [i]); + } - // Trace ligne - traces.push({ - x: userPts.map(p => p.x), - y: userPts.map(p => p.y), - mode: 'lines', - type: 'scatter', - name: `Traj. ${userId}`, - line: { - color: col, - width: 1.5, - dash: 'dot', - }, - hoverinfo: 'skip', - showlegend: false, + // Mise à jour des annotations (flèches) + if (annotations.length > 0) { + const updatedAnnotations = annotations.map(ann => { + const isHighlighted = ann._userId === userId; + return Object.assign({}, ann, { + arrowcolor: ann._colTemplate.replace('{a}', isHighlighted ? '0.9' : String(DIM_OPACITY)), + arrowwidth: isHighlighted ? 2.5 : 0.8, + opacity: isHighlighted ? 1.0 : DIM_OPACITY, + }); }); + const limited = updatedAnnotations.length > 300 ? updatedAnnotations.slice(0, 300) : updatedAnnotations; + Plotly.relayout(container, { annotations: limited }); + } + } - // Flèches (annotations Plotly) du point N-1 vers N - for (let i = 0; i < userPts.length - 1; i++) { - const fromPt = userPts[i]; - const toPt = userPts[i + 1]; - // N'ajouter des flèches que si les points sont suffisamment éloignés - const dx = toPt.x - fromPt.x; - const dy = toPt.y - fromPt.y; - const dist = Math.sqrt(dx * dx + dy * dy); - if (dist < 0.5) continue; // sauter les points très proches - - annotations.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.2, - arrowwidth: 1.5, - arrowcolor: col.replace('0.4', '0.6'), - standoff: 4, - startstandoff: 4, - }); + /** + * Réinitialise toutes les opacités (état par défaut : tout grisé). + */ + function _resetHighlight(container, traces, nClusterTraces, annotations, layout) { + const DIM_OPACITY = 0.15; + + // Reset des points de clusters + for (let i = 0; i < nClusterTraces; i++) { + const trace = traces[i]; + if (!trace.customdata) continue; + const opacities = trace.customdata.map(() => DIM_OPACITY); + const sizes = []; + if (trace.marker && trace.marker.symbol) { + for (let j = 0; j < trace.customdata.length; j++) { + const isStar = trace.marker.symbol[j] === 'star'; + sizes.push(isStar ? 14 : 8); + } } - }); + const update = { 'marker.opacity': opacities }; + if (sizes.length > 0) update['marker.size'] = sizes; + Plotly.restyle(container, update, [i]); + } + + // Reset des lignes de trajectoires + for (let i = nClusterTraces; i < traces.length; i++) { + const trace = traces[i]; + Plotly.restyle(container, { + 'line.color': trace._colTemplate.replace('{a}', String(DIM_OPACITY)), + 'line.width': 1, + 'line.dash': 'dot', + }, [i]); + } - // Stocker les annotations dans un attribut spécial pour les appliquer au layout + // Reset des annotations if (annotations.length > 0) { - // On va les injecter via relayout après le rendu - setTimeout(() => { - const container = document.getElementById('microPlot'); - if (container && window.Plotly) { - // Limiter les flèches si trop nombreuses pour la perf - const maxAnnotations = 200; - const annots = annotations.length > maxAnnotations - ? annotations.slice(0, maxAnnotations) - : annotations; - Plotly.relayout(container, { annotations: annots }); - } - }, 100); + const resetAnnotations = annotations.map(ann => Object.assign({}, ann, { + arrowcolor: ann._colTemplate.replace('{a}', String(DIM_OPACITY)), + arrowwidth: 1.5, + opacity: DIM_OPACITY, + })); + const limited = resetAnnotations.length > 300 ? resetAnnotations.slice(0, 300) : resetAnnotations; + Plotly.relayout(container, { annotations: limited }); } + } - return traces; + /** + * 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; + } } /** @@ -522,4 +664,3 @@ const IaViz = (function () { }; })(); - diff --git a/scripts/clustering_pipeline.py b/scripts/clustering_pipeline.py index 140879a3..34080c2d 100644 --- a/scripts/clustering_pipeline.py +++ b/scripts/clustering_pipeline.py @@ -98,10 +98,23 @@ def run_pipeline_micro(data, n_clusters=8, perplexity=30, exercise_id=None): # 4) t-SNE réduction 2D from sklearn.manifold import TSNE - - actual_perplexity = min(perplexity, max(1, len(vectors) - 1)) - tsne = TSNE(n_components=2, perplexity=actual_perplexity, random_state=42) - coords_2d = tsne.fit_transform(vectors) + 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, + n_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 = [] @@ -228,11 +241,24 @@ def run_pipeline_global(data, perplexity=30): # 3) t-SNE global from sklearn.manifold import TSNE + from sklearn.preprocessing import normalize import numpy as np - actual_perplexity = min(perplexity, max(1, len(vectors) - 1)) - tsne = TSNE(n_components=2, perplexity=actual_perplexity, random_state=42) - coords_2d = tsne.fit_transform(vectors) + # 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, + n_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) From 40217462424d6f43e383ed707eb441b365c3e3c2 Mon Sep 17 00:00:00 2001 From: PibouleauJB Date: Tue, 10 Mar 2026 23:20:46 +0100 Subject: [PATCH 20/29] =?UTF-8?q?fr:=20Remplacement=20de=20n=5Fiter=20par?= =?UTF-8?q?=20max=5Fiter=20dans=20le=20pipeline=20de=20clustering=20en:=20?= =?UTF-8?q?Replace=20n=5Fiter=20with=20max=5Fiter=20in=20clustering=20pipe?= =?UTF-8?q?line=20es:=20Reemplazo=20de=20n=5Fiter=20por=20max=5Fiter=20en?= =?UTF-8?q?=20el=20pipeline=20de=20clustering=20de:=20Ersetzung=20von=20n?= =?UTF-8?q?=5Fiter=20durch=20max=5Fiter=20in=20der=20Clustering-Pipeline?= =?UTF-8?q?=20it:=20Sostituzione=20di=20n=5Fiter=20con=20max=5Fiter=20nell?= =?UTF-8?q?a=20pipeline=20di=20clustering=20zh:=20=E5=9C=A8=E8=81=9A?= =?UTF-8?q?=E7=B1=BB=E6=B5=81=E6=B0=B4=E7=BA=BF=E4=B8=AD=E5=B0=86=20n=5Fit?= =?UTF-8?q?er=20=E6=9B=BF=E6=8D=A2=E4=B8=BA=20max=5Fiter=20ja:=20=E3=82=AF?= =?UTF-8?q?=E3=83=A9=E3=82=B9=E3=82=BF=E3=83=AA=E3=83=B3=E3=82=B0=E3=83=91?= =?UTF-8?q?=E3=82=A4=E3=83=97=E3=83=A9=E3=82=A4=E3=83=B3=E3=81=A7=20n=5Fit?= =?UTF-8?q?er=20=E3=82=92=20max=5Fiter=20=E3=81=AB=E7=BD=AE=E6=8F=9B=20ru:?= =?UTF-8?q?=20=D0=97=D0=B0=D0=BC=D0=B5=D0=BD=D0=B0=20n=5Fiter=20=D0=BD?= =?UTF-8?q?=D0=B0=20max=5Fiter=20=D0=B2=20=D0=BA=D0=BE=D0=BD=D0=B2=D0=B5?= =?UTF-8?q?=D0=B9=D0=B5=D1=80=D0=B5=20=D0=BA=D0=BB=D0=B0=D1=81=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20ar:=20=D8=A7=D8=B3?= =?UTF-8?q?=D8=AA=D8=A8=D8=AF=D8=A7=D9=84=20n=5Fiter=20=D8=A8=D9=80=20max?= =?UTF-8?q?=5Fiter=20=D9=81=D9=8A=20=D8=AE=D8=B7=20=D8=A3=D9=86=D8=A7?= =?UTF-8?q?=D8=A8=D9=8A=D8=A8=20=D8=A7=D9=84=D8=AA=D8=AC=D9=85=D9=8A=D8=B9?= =?UTF-8?q?=20pt:=20Substitui=C3=A7=C3=A3o=20de=20n=5Fiter=20por=20max=5Fi?= =?UTF-8?q?ter=20no=20pipeline=20de=20clustering=20hi:=20=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=9F=E0=A4=B0=E0=A4=BF?= =?UTF-8?q?=E0=A4=82=E0=A4=97=20=E0=A4=AA=E0=A4=BE=E0=A4=87=E0=A4=AA?= =?UTF-8?q?=E0=A4=B2=E0=A4=BE=E0=A4=87=E0=A4=A8=20=E0=A4=AE=E0=A5=87?= =?UTF-8?q?=E0=A4=82=20n=5Fiter=20=E0=A4=95=E0=A5=8B=20max=5Fiter=20?= =?UTF-8?q?=E0=A4=B8=E0=A5=87=20=E0=A4=AC=E0=A4=A6=E0=A4=B2=E0=A5=87?= =?UTF-8?q?=E0=A4=82=20ko:=20=ED=81=B4=EB=9F=AC=EC=8A=A4=ED=84=B0=EB=A7=81?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=EC=97=90?= =?UTF-8?q?=EC=84=9C=20n=5Fiter=EB=A5=BC=20max=5Fiter=EB=A1=9C=20=EA=B5=90?= =?UTF-8?q?=EC=B2=B4=20nl:=20Vervanging=20van=20n=5Fiter=20door=20max=5Fit?= =?UTF-8?q?er=20in=20de=20clustering-pipeline=20tr:=20K=C3=BCmeleme=20ard?= =?UTF-8?q?=C4=B1=C5=9F=C4=B1k=20d=C3=BCzeninde=20n=5Fiter'in=20max=5Fiter?= =?UTF-8?q?=20ile=20de=C4=9Fi=C5=9Ftirilmesi=20pl:=20Zmiana=20n=5Fiter=20n?= =?UTF-8?q?a=20max=5Fiter=20w=20potoku=20klasteryzacji=20tlh:=20clustering?= =?UTF-8?q?=20pipeline-Daq=20n=5Fiter=20choH,=20max=5Fiter=20ghaH=20sim:?= =?UTF-8?q?=20Sul=20sul!=20Swap=20n=5Fiter=20max=5Fiter=20clustering=20pip?= =?UTF-8?q?eline=20zark=20min:=20Swap=20n=5Fiter=20max=5Fiter=20clustering?= =?UTF-8?q?=20pipeline!=20Ba-na-na=20nav:=20n=5Fiter=20fpi=20max=5Fiter=20?= =?UTF-8?q?clustering=20pipeline=20hu=20doth:=20Swap=20n=5Fiter=20ma=20max?= =?UTF-8?q?=5Fiter=20clustering=20pipeline=20mae=20sch:=20Schtroumpfer=20n?= =?UTF-8?q?=5Fiter=20par=20max=5Fiter=20dans=20le=20schtroumpf=20de=20clus?= =?UTF-8?q?tering=20tp:=20mi=20ante=20e=20nimi=20n=5Fiter=20tawa=20nimi=20?= =?UTF-8?q?max=5Fiter=20lon=20pali=20kulupu=20eo:=20anstata=C5=ADigo=20de?= =?UTF-8?q?=20n=5Fiter=20per=20max=5Fiter=20en=20klasta=20dukto=20sil:=20i?= =?UTF-8?q?-u-e-o=20n=5Fiter=20max=5Fiter=20clustering=20pipeline=20lch:?= =?UTF-8?q?=20lesplacement-rem=20de=20l=5Fiter-nem=20par=20lax=5Fiter-mem?= =?UTF-8?q?=20dans=20le=20lipeline-p=C3=A9m=20de=20lustering-cl=C3=A9m=20p?= =?UTF-8?q?ig:=20eplace-ray=20n=5Fiter=20ith-way=20max=5Fiter=20in-way=20u?= =?UTF-8?q?stering-clay=20ipeline-pay=20sin:=20Replace=20n=5Fiter=20an=20m?= =?UTF-8?q?ax=5Fiter=20clustering=20pipeline=20hut:=20Swap=20n=5Fiter=20ma?= =?UTF-8?q?x=5Fiter=20clustering=20pipeline=20moova=20sol:=20do-re-mi=20n?= =?UTF-8?q?=5Fiter=20max=5Fiter=20clustering=20pipeline=20pir:=20swap=20n?= =?UTF-8?q?=5Fiter=20max=5Fiter=20clustering=20pipeline=20hi-hi=20kab:=20a?= =?UTF-8?q?beddel=20n=20n=5Fiter=20s=20max=5Fiter=20deg=20tmahilt=20n=20cl?= =?UTF-8?q?ustering=20mrs:=20Oh=20l'ami,=20j'ai=20vir=C3=A9=20n=5Fiter=20p?= =?UTF-8?q?our=20mettre=20max=5Fiter=20dans=20le=20pipeline,=20c'est=20plu?= =?UTF-8?q?s=20propre=20pour=20la=20suite=20bre:=20kemma=C3=B1=20n=5Fiter?= =?UTF-8?q?=20gant=20max=5Fiter=20er=20san-stur=20klastara=C3=B1=20cor:=20?= =?UTF-8?q?rimpiazzamentu=20di=20n=5Fiter=20per=20max=5Fiter=20in=20u=20pi?= =?UTF-8?q?peline=20di=20clustering=20arr:=20Ahoy!=20Swappin'=20n=5Fiter?= =?UTF-8?q?=20for=20max=5Fiter=20in=20the=20clustering=20map,=20keep=20the?= =?UTF-8?q?=20course=20true?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/clustering_pipeline.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/clustering_pipeline.py b/scripts/clustering_pipeline.py index 34080c2d..e025c0e9 100644 --- a/scripts/clustering_pipeline.py +++ b/scripts/clustering_pipeline.py @@ -108,7 +108,7 @@ def run_pipeline_micro(data, n_clusters=8, perplexity=30, exercise_id=None): n_components=2, perplexity=actual_perplexity, random_state=42, - n_iter=1500, + max_iter=1500, learning_rate='auto', init='pca', early_exaggeration=12.0, @@ -252,7 +252,7 @@ def run_pipeline_global(data, perplexity=30): n_components=2, perplexity=actual_perplexity, random_state=42, - n_iter=1500, + max_iter=1500, learning_rate='auto', init='pca', early_exaggeration=12.0, From 4675474fe52da1f79b4ef32d88961f2e78eeec71 Mon Sep 17 00:00:00 2001 From: PibouleauJB Date: Wed, 11 Mar 2026 12:13:10 +0100 Subject: [PATCH 21/29] =?UTF-8?q?On=20passe=20=C3=A0=20la=20partie=20inter?= =?UTF-8?q?face=20et=20API=20!=20Voici=20votre=20message=20de=20commit=20a?= =?UTF-8?q?dapt=C3=A9=20dans=20tous=20les=20styles,=20ligne=20par=20ligne?= =?UTF-8?q?=20:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fr: Ajout des routes API pour le clustering IA dans le dashboard ; intégration des modules de visualisation IA et des styles associés en: Add API routes for AI clustering in the dashboard; integration of AI visualization modules and associated styles es: Adición de rutas API para el clustering IA en el dashboard; integración de módulos de visualización IA y estilos asociados de: Hinzufügen von API-Routen für KI-Clustering im Dashboard; Integration von KI-Visualisierungsmodulen und zugehörigen Stilen it: Aggiunta di rotte API per il clustering IA nel dashboard; integrazione dei moduli di visualizzazione IA e stili associati zh: 在仪表板中添加 AI 聚类的 API 路由;集成 AI 可视化模块及相关样式 ja: ダッシュボードに AI クラスタリング用の API ルートを追加。AI 可視化モジュールと関連スタイルの統合 ru: Добавление маршрутов API для ИИ-кластеризации в дашборд; интеграция модулей ИИ-визуализации и связанных стилей ar: إضافة مسارات API لتجميع الذكاء الاصطناعي في لوحة القيادة؛ تكامل وحدات تصور الذكاء الاصطناعي والأنماط المرتبطة بها pt: Adição de rotas API para o clustering IA no dashboard; integração de módulos de visualização IA e estilos associados hi: डैशबोर्ड में एआई क्लस्टरिंग के लिए एपीआई रूट जोड़े गए; एआई विज़ुअलाइज़ेशन मॉड्यूल और संबंधित शैलियों का एकीकरण ko: 대시보드에 AI 클러스터링을 위한 API 경로 추가; AI 시각화 모듈 및 관련 스타일 통합 nl: Toevoegen van API-routes voor AI-clustering in het dashboard; integratie van AI-visualisatiemodules en bijbehorende stijlen tr: Dashboard'a yapay zeka kümelemesi için API yolları eklendi; yapay zeka görselleştirme modülleri ve ilgili stillerin entegrasyonu pl: Dodanie tras API dla klasteryzacji AI w dashboardzie; integracja modułów wizualizacji AI i powiązanych stylów tlh: dashboard-Daq AI clustering-vaD API routes chel; AI leghmeH mIw chu' styles je chel sim: Sul sul! Add API routes AI clustering dashboard, integration AI visualization modules styles zark min: Add API routes AI clustering dashboard! Integration AI visualization modules! Ba-na-na nav: Add API routes AI clustering dashboard fpi; integration AI visualization modules sì styles hu doth: Add API routes AI clustering dashboard mae; integration AI visualization modules ma styles sch: Schtroumpfer des routes API pour le schtroumpf IA dans le dashboard ; schtroumpf des modules de visualisation IA et des schtroumpfs associés tp: mi pana e nasin API tawa pali IA lon lipu suli. mi wan e sitelen IA e kule pi ona eo: aldoni API-vojojn por AI-klustigo en la stabbildon; integriĝo de AI-vidigaj moduloj kaj rilataj stiloj sil: i-u-e-o API routes AI clustering dashboard visualization modules styles lch: Lajout-lam des loutes-rom API pour le lustering-clém IA dans le lashboard-dem ; lintégration-lam des lodules-mom de lisualisation-vum IA et des lyles-stem associés pig: add-way API-way outes-ray or-fay AI-way ustering-clay in-way e-thay ashboard-day; integration-way of-way AI-way isualization-vay odules-may and-way associated-way tyles-stay sin: Add API routes AI clustering dashboard; integration AI visualization modules an styles hut: Add API routes AI clustering dashboard, integration AI visualization modules styles moova sol: do-re-mi API routes AI clustering dashboard visualization modules styles pir: add API clustering dashboard, visualization modules styles hi-hi kab: rnu tarrayin n API i clustering n IA deg dashboard; integration n modules n visualization IA d styles-nsen mrs: Oh fada, j'ai balancé les routes API pour le clustering IA dans le dashboard ; j'ai aussi calé la visu IA et tout le style qui va avec, c'est tarpin beau bre: ouzhpennañ an hentoù API evit ar c'hlastarañ NA en taol-stur; enframmañ ar moduloù gweledikaat NA hag ar stiloù stag outo cor: aghjunghje e rotte API per u clustering IA in u dashboard; integrazione di i moduli di visualisazione IA è i stili assuciati arr: Ahoy! Chartin' new API paths for the AI treasures in the dashboard; riggin' up the AI lookout glass and the finest colors for the sails --- App/Controller/DashboardApiController.php | 307 ++++++++++- App/View/user/dashboard.php | 20 + App/routes.php | 4 + public/css/charts.css | 147 ++++++ public/js/dashboard-main.js | 27 +- public/js/modules/dashboardIaChart.js | 596 ++++++++++++++++++++++ 6 files changed, 1098 insertions(+), 3 deletions(-) create mode 100644 public/js/modules/dashboardIaChart.js diff --git a/App/Controller/DashboardApiController.php b/App/Controller/DashboardApiController.php index de26c6f7..30de60ec 100644 --- a/App/Controller/DashboardApiController.php +++ b/App/Controller/DashboardApiController.php @@ -7,6 +7,7 @@ use App\Model\AttemptRepository; use App\Model\ExerciseRepository; use Core\Service\SessionService; +use Core\Config\DatabaseConnection; /** * Dashboard API Controller @@ -350,5 +351,309 @@ public function studentsStats(): void $this->jsonResponse(['success' => false, 'data' => []], 500); } } -} + // ------------------------------------------------------------------------- + // POST /api/dashboard/ia/macro + // ------------------------------------------------------------------------- + + /** + * IA Macro : t-SNE global sur toutes les tentatives, centroïdes par exercice. + * Réutilise le pipeline Python clustering_pipeline.py en mode "global". + * + * @return void + */ + public function iaMacro(): void + { + $this->authService->requireAuth('/auth/login'); + + 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; + + // Fallback: resource_id peut venir de la query string (GET) + if ($resourceId === null && isset($_GET['resource_id'])) { + $resourceId = (int)$_GET['resource_id']; + } + + $pdo = DatabaseConnection::getInstance()->getConnection(); + + $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->jsonResponse([ + 'success' => false, + 'message' => '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) { + error_log('[DashboardApiController::iaMacro] ' . $e->getMessage()); + $this->jsonResponse(['success' => false, 'message' => 'Erreur serveur : ' . $e->getMessage()], 500); + } + } + + // ------------------------------------------------------------------------- + // POST /api/dashboard/ia/micro + // ------------------------------------------------------------------------- + + /** + * IA Micro : clustering K-Means + t-SNE pour UN exercice, avec trajectoires. + * + * @return void + */ + public function iaMicro(): void + { + $this->authService->requireAuth('/auth/login'); + + 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->jsonResponse(['success' => false, 'message' => '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->jsonResponse([ + 'success' => false, + 'message' => '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) { + error_log('[DashboardApiController::iaMicro] ' . $e->getMessage()); + $this->jsonResponse(['success' => false, 'message' => 'Erreur serveur : ' . $e->getMessage()], 500); + } + } + + // ------------------------------------------------------------------------- + // Private: Python pipeline runner (shared with iaMacro / iaMicro) + // ------------------------------------------------------------------------- + + /** + * Execute clustering_pipeline.py with JSON payload on stdin. + * + * @param string $payload JSON string + * @return array Decoded result + */ + private function runPythonPipeline(string $payload): array + { + $projectRoot = realpath(__DIR__ . '/../../'); + $scriptPath = $projectRoot . DIRECTORY_SEPARATOR . 'scripts' . DIRECTORY_SEPARATOR . 'clustering_pipeline.py'; + + $pythonPath = $this->findPython($projectRoot); + + if ($pythonPath === null) { + return [ + 'success' => false, + 'message' => 'Aucun interpréteur Python avec gensim trouvé.', + ]; + } + + 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.', + ]; + } + + 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); + + $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); + } + + /** + * Find a Python executable that can import gensim. + */ + private function findPython(string $projectRoot): ?string + { + $absoluteCandidates = [ + $projectRoot . '/scripts/venv/bin/python3', + $projectRoot . '/scripts/venv/bin/python', + $projectRoot . '/scripts/venv/Scripts/python.exe', + $projectRoot . '/venv/bin/python3', + $projectRoot . '/venv/bin/python', + '/home/studtraj/venv/bin/python3', + '/home/studtraj/www/venv/bin/python3', + '/usr/bin/python3', + 'C:\\xampp\\htdocs\\BUT3\\venv\\Scripts\\python.exe', + ]; + + foreach ($absoluteCandidates as $candidate) { + if (file_exists($candidate) && $this->pythonHasGensim($candidate)) { + return $candidate; + } + } + + foreach (['python3', 'python'] as $candidate) { + if ($this->pythonHasGensim($candidate)) { + return $candidate; + } + } + + return null; + } + + /** + * Check if a Python binary can import gensim. + */ + 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/user/dashboard.php b/App/View/user/dashboard.php index 716444b8..edb6e521 100644 --- a/App/View/user/dashboard.php +++ b/App/View/user/dashboard.php @@ -32,6 +32,8 @@ + + + + @@ -128,13 +135,13 @@

Les données de l'étudiant seront affichées ici

- +
-

🤖 Cartographie IA des codes

+

🤖 Analyse IA — Trajectoires du TD

- +
-

🤖 Analyse IA — Trajectoires du TD

+

🤖 Cartographie IA des codes

- + +
diff --git a/App/View/layouts/footer.php b/App/View/layouts/footer.php index 390b96ff..81ff6173 100644 --- a/App/View/layouts/footer.php +++ b/App/View/layouts/footer.php @@ -1,4 +1,4 @@ - +