Files
webval/api/chatbot.php
2026-03-27 09:58:51 +01:00

267 lines
8.6 KiB
PHP

<?php
/**
* API Chatbot IA - MVP
* Modèle : qwen2-math:1.5b via Ollama local
*/
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../config/database.php';
header('Content-Type: application/json');
// Vérifier authentification élève
if (!isLoggedIn() || !isEleve()) {
echo json_encode(['error' => 'Non autorisé']);
exit;
}
$user = currentUser();
$db = Database::getInstance();
// Récupérer et valider les données
$id_tentative = filter_input(INPUT_POST, 'id_tentative', FILTER_VALIDATE_INT);
$id_question = filter_input(INPUT_POST, 'id_question', FILTER_VALIDATE_INT);
$message_eleve = trim($_POST['message'] ?? '');
if (!$id_tentative || !$id_question || empty($message_eleve)) {
echo json_encode(['error' => 'Données manquantes']);
exit;
}
// Vérifier que la tentative appartient à l'élève et est en cours
$tentative = $db->fetchOne(
"SELECT id_eleve FROM tentatives_eleves WHERE id_tentative = ? AND statut = 'en_cours'",
[$id_tentative]
);
if (!$tentative || $tentative['id_eleve'] != $user['id_utilisateur']) {
echo json_encode(['error' => 'Tentative invalide']);
exit;
}
// Vérifier limite (3 messages max par question)
$usage = $db->fetchOne(
"SELECT messages_count FROM chat_usage_limits WHERE id_tentative = ? AND id_question = ?",
[$id_tentative, $id_question]
);
$messages_count = $usage['messages_count'] ?? 0;
if ($messages_count >= 3) {
echo json_encode([
'error' => 'Limite atteinte',
'message' => 'Tu as utilisé toute ton aide pour cette question (3/3). Continue, tu peux le faire ! 💪',
'remaining' => 0
]);
exit;
}
// Récupérer l'énoncé de la question (SANS la réponse)
$question = $db->fetchOne(
"SELECT q.enonce, q.type_question, e.titre as titre_eval
FROM questions q
JOIN evaluations e ON q.id_evaluation = e.id_evaluation
WHERE q.id_question = ?",
[$id_question]
);
if (!$question) {
echo json_encode(['error' => 'Question introuvable']);
exit;
}
$enonce_question = $question['enonce'];
$titre_eval = $question['titre_eval'];
$type_question = $question['type_question'];
// Récupérer ou créer la conversation
$conversation = $db->fetchOne(
"SELECT id FROM chat_conversations
WHERE id_tentative = ? AND id_question = ?",
[$id_tentative, $id_question]
);
if (!$conversation) {
// Créer nouvelle conversation
$id_conversation = $db->insert(
"INSERT INTO chat_conversations (id_tentative, id_question, id_eleve, modele_utilise)
VALUES (?, ?, ?, 'qwen2-math:1.5b')",
[$id_tentative, $id_question, $user['id_utilisateur']]
);
} else {
$id_conversation = $conversation['id'];
}
// Récupérer l'historique de la conversation
$historique = $db->fetchAll(
"SELECT role, message FROM chat_messages
WHERE id_conversation = ?
ORDER BY timestamp ASC",
[$id_conversation]
);
// Numéro d'aide actuel (1, 2 ou 3)
$numero_aide = $messages_count + 1;
$system_prompt = "Tu es un assistant pédagogique en mathématiques pour collège/lycée.
L'élève a droit à exactement 3 aides par question. C'est son aide numéro {$numero_aide}/3.
═══════════════════════════════════════
RÈGLE ABSOLUE : NE JAMAIS DONNER LA RÉPONSE
═══════════════════════════════════════
Même si l'élève demande directement, même s'il insiste, même s'il dit avoir cherché :
TU NE DONNES JAMAIS la réponse, ni le résultat, ni le mot/nombre final attendu.
Cela inclut : nommer la transformation, donner le résultat d'un calcul, compléter la phrase avec la solution.
LONGUEUR : Réponds en 2-4 phrases MAXIMUM. Sois direct et concis.
SELON LE NUMÉRO D'AIDE :
- Aide 1/3 : Question ouverte pour faire réfléchir. Ex: \"Qu'est-ce qui change entre la figure de départ et la figure d'arrivée ?\"
- Aide 2/3 : Indice plus ciblé sur la méthode. Ex: \"Regarde si la figure a été déplacée, tournée, ou agrandie...\"
- Aide 3/3 : Démarche pas-à-pas SANS donner le mot final. Ex: \"Compare les coordonnées : le point A(1,2) devient A'(4,2). Que remarques-tu sur x ? Sur y ?\"
CE QUE TU NE FAIS JAMAIS :
\"La réponse est...\" / \"C'est une translation\" / \"Le résultat est 17\"
❌ Confirmer si l'élève a trouvé la bonne réponse
❌ Faire le calcul complet à sa place
❌ Réponses de plus de 4 phrases
CE QUE TU FAIS :
✅ Poser UNE question ciblée
✅ Donner UN indice de méthode
✅ Orienter sans révéler
ÉNONCÉ DE LA QUESTION (contexte uniquement - NE PAS RÉSOUDRE) :
" . strip_tags($enonce_question);
// Construire l'historique pour Ollama
$messages_ollama = [];
foreach ($historique as $msg) {
$messages_ollama[] = [
'role' => $msg['role'],
'content' => $msg['message']
];
}
// Ajouter le nouveau message de l'élève
$messages_ollama[] = [
'role' => 'user',
'content' => $message_eleve
];
// Appel à Ollama (qwen2-math:1.5b)
$start_time = microtime(true);
$ch = curl_init('http://localhost:11434/api/chat');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_TIMEOUT => 30,
CURLOPT_POSTFIELDS => json_encode([
'model' => 'ministral-3:3b',
'messages' => array_merge([
['role' => 'system', 'content' => $system_prompt]
], $messages_ollama),
'stream' => false,
'options' => [
'temperature' => 0.7,
'num_predict' => 200, // Réponses courtes
'top_p' => 0.9
]
])
]);
$response = curl_exec($ch);
$curl_error = curl_error($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$end_time = microtime(true);
$temps_reponse_ms = round(($end_time - $start_time) * 1000);
// Gérer les erreurs
if ($curl_error) {
error_log("Erreur Ollama CURL: $curl_error");
echo json_encode(['error' => 'Service temporairement indisponible (connexion)']);
exit;
}
if ($http_code !== 200) {
error_log("Erreur Ollama HTTP $http_code: $response");
echo json_encode(['error' => 'Service temporairement indisponible (HTTP ' . $http_code . ')']);
exit;
}
$data = json_decode($response, true);
if (!isset($data['message']['content'])) {
error_log("Réponse Ollama invalide: $response");
echo json_encode(['error' => 'Erreur de génération']);
exit;
}
$reponse_ia = trim($data['message']['content']);
// ============================================================
// DÉTECTION : la réponse de l'IA contient-elle la solution ?
// Si oui, on remplace par un message neutre
// ============================================================
function detecterReponseDirecte($texte, $enonce) {
$texte_lower = mb_strtolower($texte);
// Patterns suspects : formulations qui donnent directement la réponse
$patterns_suspects = [
'/la\s+r[eé]ponse\s+est/i',
'/c\'?est\s+(une?\s+)?(translation|rotation|homoth|sym[eé]trie|vecteur)/i',
'/il\s+s\'?agit\s+(d\'?une?|du)/i',
'/on\s+obtient\s*:/i',
'/le\s+r[eé]sultat\s+est/i',
'/donc\s*[=:]\s*\d/i',
'/la\s+solution\s+est/i',
'/r[eé]ponse\s+correcte/i',
];
foreach ($patterns_suspects as $pattern) {
if (preg_match($pattern, $texte)) {
error_log("Chatbot: réponse directe détectée, remplacement. Pattern: $pattern");
return true;
}
}
return false;
}
if (detecterReponseDirecte($reponse_ia, $enonce_question)) {
$reponse_ia = "Je ne peux pas te donner la réponse directement, mais voici un indice : relis bien l'énoncé et demande-toi quelle transformation modifie la position d'une figure sans changer sa forme ni sa taille. Qu'est-ce que tu remarques ?";
}
// Sauvegarder le message de l'élève
$db->query(
"INSERT INTO chat_messages (id_conversation, role, message)
VALUES (?, 'user', ?)",
[$id_conversation, $message_eleve]
);
// Sauvegarder la réponse de l'IA
$db->query(
"INSERT INTO chat_messages (id_conversation, role, message, temps_reponse_ms)
VALUES (?, 'assistant', ?, ?)",
[$id_conversation, $reponse_ia, $temps_reponse_ms]
);
// Incrémenter le compteur d'utilisation
$db->query(
"INSERT INTO chat_usage_limits (id_tentative, id_question, messages_count)
VALUES (?, ?, 1)
ON DUPLICATE KEY UPDATE messages_count = messages_count + 1",
[$id_tentative, $id_question]
);
$messages_restants = 2 - $messages_count;
// Réponse succès
echo json_encode([
'success' => true,
'response' => $reponse_ia,
'remaining' => $messages_restants,
'temps_ms' => $temps_reponse_ms
]);