diff --git a/config/api_key_manager.php b/config/api_key_manager.php new file mode 100644 index 0000000..831c7d8 --- /dev/null +++ b/config/api_key_manager.php @@ -0,0 +1,455 @@ + Fichier > Génération + */ + private static function getEncryptionKey() { + if (self::$encryption_key !== null) { + return self::$encryption_key; + } + + // 1. Variable d'environnement + $key = getenv('WEBVAL_API_ENCRYPTION_KEY'); + + // 2. Fichier externe (hors webroot) + if (!$key && file_exists('/etc/webval/encryption.key')) { + $key = file_get_contents('/etc/webval/encryption.key'); + } + + // 3. Fichier dans config (fallback) + $keyFile = __DIR__ . '/../config/.encryption_key'; + if (!$key && file_exists($keyFile)) { + $key = file_get_contents($keyFile); + } + + // 4. Générer une nouvelle clé si aucune n'existe + if (!$key) { + $key = bin2hex(random_bytes(32)); + + // Essayer de sauvegarder + if (is_writable(__DIR__ . '/../config/')) { + file_put_contents($keyFile, $key); + chmod($keyFile, 0600); + } + + error_log("⚠️ Nouvelle clé de chiffrement générée. Sauvegarde recommandée."); + } + + self::$encryption_key = $key; + return $key; + } + + /** + * Chiffrer une clé API + * + * @param string $plaintext Clé API en clair + * @return string Clé chiffrée (base64) + */ + public static function encrypt($plaintext) { + if (empty($plaintext)) { + return null; + } + + $key = hash('sha256', self::getEncryptionKey(), true); + $iv = random_bytes(16); + + $ciphertext = openssl_encrypt( + $plaintext, + 'AES-256-CBC', + $key, + OPENSSL_RAW_DATA, + $iv + ); + + if ($ciphertext === false) { + throw new Exception("Erreur de chiffrement"); + } + + // Format: IV (16 bytes) + Ciphertext + return base64_encode($iv . $ciphertext); + } + + /** + * Déchiffrer une clé API + * + * @param string $encrypted Clé chiffrée (base64) + * @return string|null Clé API en clair + */ + public static function decrypt($encrypted) { + if (empty($encrypted)) { + return null; + } + + $key = hash('sha256', self::getEncryptionKey(), true); + $data = base64_decode($encrypted); + + if ($data === false || strlen($data) < 16) { + throw new Exception("Données chiffrées invalides"); + } + + $iv = substr($data, 0, 16); + $ciphertext = substr($data, 16); + + $plaintext = openssl_decrypt( + $ciphertext, + 'AES-256-CBC', + $key, + OPENSSL_RAW_DATA, + $iv + ); + + if ($plaintext === false) { + throw new Exception("Erreur de déchiffrement"); + } + + return $plaintext; + } + + /** + * Sauvegarder une clé API + * + * @param int $id_enseignant ID de l'enseignant + * @param string $provider Provider (gemini, mistral, etc.) + * @param string $api_key Clé API en clair + * @param string $model_default Modèle par défaut + * @return bool Succès + */ + public static function saveKey($id_enseignant, $provider, $api_key, $model_default = null) { + self::init(); + + // Chiffrer la clé + $encrypted = self::encrypt($api_key); + + // Upsert (INSERT ... ON DUPLICATE KEY UPDATE) + $query = "INSERT INTO api_keys + (id_enseignant, provider, api_key_encrypted, model_default, is_active, quota_reset_date) + VALUES (?, ?, ?, ?, TRUE, DATE_ADD(CURDATE(), INTERVAL 1 MONTH)) + ON DUPLICATE KEY UPDATE + api_key_encrypted = VALUES(api_key_encrypted), + model_default = VALUES(model_default), + is_active = TRUE, + updated_at = CURRENT_TIMESTAMP"; + + return self::$db->query($query, [ + $id_enseignant, + $provider, + $encrypted, + $model_default + ]); + } + + /** + * Récupérer une clé API déchiffrée + * + * @param int $id_enseignant ID de l'enseignant + * @param string $provider Provider + * @return array|null ['api_key' => '...', 'model_default' => '...'] + */ + public static function getKey($id_enseignant, $provider) { + self::init(); + + $result = self::$db->fetchOne( + "SELECT api_key_encrypted, model_default, is_active + FROM api_keys + WHERE id_enseignant = ? AND provider = ?", + [$id_enseignant, $provider] + ); + + if (!$result || !$result['is_active']) { + return null; + } + + return [ + 'api_key' => self::decrypt($result['api_key_encrypted']), + 'model_default' => $result['model_default'] + ]; + } + + /** + * Supprimer une clé API + * + * @param int $id_enseignant ID de l'enseignant + * @param string $provider Provider + * @return bool Succès + */ + public static function deleteKey($id_enseignant, $provider) { + self::init(); + + return self::$db->query( + "DELETE FROM api_keys WHERE id_enseignant = ? AND provider = ?", + [$id_enseignant, $provider] + ); + } + + /** + * Lister toutes les clés d'un enseignant + * + * @param int $id_enseignant ID de l'enseignant + * @return array Liste des clés (sans les clés déchiffrées) + */ + public static function listKeys($id_enseignant) { + self::init(); + + return self::$db->fetchAll( + "SELECT + id, + provider, + model_default, + is_active, + quota_max, + quota_used, + quota_reset_date, + last_used, + created_at + FROM api_keys + WHERE id_enseignant = ? + ORDER BY provider", + [$id_enseignant] + ); + } + + /** + * Incrémenter le quota utilisé + * + * @param int $id_enseignant ID de l'enseignant + * @param string $provider Provider + * @param int $tokens_used Nombre de tokens utilisés + */ + public static function incrementQuota($id_enseignant, $provider, $tokens_used = 1) { + self::init(); + + // Reset quota si date dépassée + self::$db->query( + "UPDATE api_keys + SET quota_used = 0, quota_reset_date = DATE_ADD(CURDATE(), INTERVAL 1 MONTH) + WHERE id_enseignant = ? AND provider = ? AND quota_reset_date < CURDATE()", + [$id_enseignant, $provider] + ); + + // Incrémenter + return self::$db->query( + "UPDATE api_keys + SET quota_used = quota_used + ?, last_used = NOW() + WHERE id_enseignant = ? AND provider = ?", + [$tokens_used, $id_enseignant, $provider] + ); + } + + /** + * Vérifier si le quota est dépassé + * + * @param int $id_enseignant ID de l'enseignant + * @param string $provider Provider + * @return bool True si quota OK + */ + public static function checkQuota($id_enseignant, $provider) { + self::init(); + + $result = self::$db->fetchOne( + "SELECT quota_max, quota_used FROM api_keys + WHERE id_enseignant = ? AND provider = ?", + [$id_enseignant, $provider] + ); + + if (!$result || $result['quota_max'] === null) { + return true; // Pas de limite + } + + return $result['quota_used'] < $result['quota_max']; + } + + /** + * Tester une connexion API + * + * @param string $provider Provider à tester + * @param string $api_key Clé API + * @param string $model Modèle à tester + * @return array ['success' => bool, 'message' => string, 'latency_ms' => int] + */ + public static function testConnection($provider, $api_key, $model = null) { + $start = microtime(true); + + try { + switch ($provider) { + case 'gemini': + $result = self::testGemini($api_key, $model ?? 'gemini-2.0-flash-exp'); + break; + + case 'mistral': + $result = self::testMistral($api_key, $model ?? 'ministral-3b-latest'); + break; + + case 'openai': + $result = self::testOpenAI($api_key, $model ?? 'gpt-4o-mini'); + break; + + case 'ollama': + $result = self::testOllama($model ?? 'ministral-3:3b'); + break; + + default: + return ['success' => false, 'message' => 'Provider inconnu']; + } + + $latency = round((microtime(true) - $start) * 1000); + $result['latency_ms'] = $latency; + + return $result; + + } catch (Exception $e) { + return [ + 'success' => false, + 'message' => 'Erreur: ' . $e->getMessage(), + 'latency_ms' => 0 + ]; + } + } + + /** + * Tester connexion Gemini + */ + private static function testGemini($api_key, $model) { + $url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$api_key}"; + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + CURLOPT_TIMEOUT => 10, + CURLOPT_POSTFIELDS => json_encode([ + 'contents' => [['parts' => [['text' => 'Test']]]] + ]) + ]); + + $response = curl_exec($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($http_code === 200) { + return ['success' => true, 'message' => 'Connexion Gemini réussie']; + } elseif ($http_code === 400) { + return ['success' => false, 'message' => 'Clé API invalide']; + } elseif ($http_code === 429) { + return ['success' => false, 'message' => 'Quota dépassé']; + } else { + return ['success' => false, 'message' => "Erreur HTTP $http_code"]; + } + } + + /** + * Tester connexion Mistral + */ + private static function testMistral($api_key, $model) { + $ch = curl_init('https://api.mistral.ai/v1/chat/completions'); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'Authorization: Bearer ' . $api_key + ], + CURLOPT_TIMEOUT => 10, + CURLOPT_POSTFIELDS => json_encode([ + 'model' => $model, + 'messages' => [['role' => 'user', 'content' => 'Test']], + 'max_tokens' => 10 + ]) + ]); + + $response = curl_exec($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($http_code === 200) { + return ['success' => true, 'message' => 'Connexion Mistral réussie']; + } elseif ($http_code === 401) { + return ['success' => false, 'message' => 'Clé API invalide']; + } else { + return ['success' => false, 'message' => "Erreur HTTP $http_code"]; + } + } + + /** + * Tester connexion OpenAI + */ + private static function testOpenAI($api_key, $model) { + $ch = curl_init('https://api.openai.com/v1/chat/completions'); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'Authorization: Bearer ' . $api_key + ], + CURLOPT_TIMEOUT => 10, + CURLOPT_POSTFIELDS => json_encode([ + 'model' => $model, + 'messages' => [['role' => 'user', 'content' => 'Test']], + 'max_tokens' => 10 + ]) + ]); + + $response = curl_exec($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($http_code === 200) { + return ['success' => true, 'message' => 'Connexion OpenAI réussie']; + } elseif ($http_code === 401) { + return ['success' => false, 'message' => 'Clé API invalide']; + } else { + return ['success' => false, 'message' => "Erreur HTTP $http_code"]; + } + } + + /** + * Tester connexion Ollama (local) + */ + private static function testOllama($model) { + $ch = curl_init('http://localhost:11434/api/generate'); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + CURLOPT_TIMEOUT => 5, + CURLOPT_POSTFIELDS => json_encode([ + 'model' => $model, + 'prompt' => 'Test', + 'stream' => false + ]) + ]); + + $response = curl_exec($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($http_code === 200) { + return ['success' => true, 'message' => 'Connexion Ollama réussie']; + } else { + return ['success' => false, 'message' => 'Ollama non disponible']; + } + } +} diff --git a/sql/chatbot_api_management.sql b/sql/chatbot_api_management.sql new file mode 100644 index 0000000..358ba5a --- /dev/null +++ b/sql/chatbot_api_management.sql @@ -0,0 +1,136 @@ +-- ============================================ +-- WebVal - Gestion API Chatbot +-- Tables pour la gestion des clés API et modèles +-- Date: 2026-01-04 +-- ============================================ + +-- Table des clés API des enseignants +CREATE TABLE IF NOT EXISTS api_keys ( + id INT AUTO_INCREMENT PRIMARY KEY, + id_enseignant INT NOT NULL, + provider ENUM('gemini', 'mistral', 'openai', 'anthropic', 'ollama') NOT NULL, + api_key_encrypted TEXT, -- Clé chiffrée avec AES-256-CBC + model_default VARCHAR(100), -- Modèle par défaut (ex: 'gemini-2.0-flash-exp') + is_active BOOLEAN DEFAULT TRUE, + quota_max INT DEFAULT NULL, -- Limite mensuelle optionnelle + quota_used INT DEFAULT 0, + quota_reset_date DATE, -- Date de reset du quota + last_used TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (id_enseignant) REFERENCES utilisateurs(id_utilisateur) ON DELETE CASCADE, + UNIQUE KEY unique_provider (id_enseignant, provider), + INDEX idx_enseignant (id_enseignant), + INDEX idx_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Table des modèles disponibles (catalogue) +CREATE TABLE IF NOT EXISTS available_models ( + id INT AUTO_INCREMENT PRIMARY KEY, + provider VARCHAR(50) NOT NULL, + model_id VARCHAR(100) NOT NULL, -- ID technique du modèle + display_name VARCHAR(150) NOT NULL, -- Nom affiché + description TEXT, + context_length INT DEFAULT 4096, -- Taille contexte en tokens + is_free BOOLEAN DEFAULT FALSE, + cost_per_1m_tokens DECIMAL(10,4) DEFAULT NULL, -- Coût par million de tokens + recommended BOOLEAN DEFAULT FALSE, + speed_rating TINYINT DEFAULT 3, -- 1=lent, 5=très rapide + quality_rating TINYINT DEFAULT 3, -- 1=basique, 5=excellent + active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY unique_model (provider, model_id), + INDEX idx_provider (provider), + INDEX idx_recommended (recommended), + INDEX idx_active (active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Table de configuration chatbot par évaluation +CREATE TABLE IF NOT EXISTS chatbot_eval_config ( + id INT AUTO_INCREMENT PRIMARY KEY, + id_evaluation INT NOT NULL, + id_enseignant INT NOT NULL, + provider_default VARCHAR(50) DEFAULT 'ollama', + model_default VARCHAR(100) DEFAULT 'ministral-3:3b', + allow_student_override BOOLEAN DEFAULT FALSE, -- L'élève peut-il choisir ? + max_messages_per_question INT DEFAULT 3, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (id_evaluation) REFERENCES evaluations(id_evaluation) ON DELETE CASCADE, + FOREIGN KEY (id_enseignant) REFERENCES utilisateurs(id_utilisateur) ON DELETE CASCADE, + UNIQUE KEY unique_eval (id_evaluation), + INDEX idx_enseignant (id_enseignant) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Table de configuration par élève (override) +CREATE TABLE IF NOT EXISTS chatbot_student_config ( + id INT AUTO_INCREMENT PRIMARY KEY, + id_evaluation INT NOT NULL, + id_eleve INT NOT NULL, + provider_override VARCHAR(50), + model_override VARCHAR(100), + created_by INT NOT NULL, -- ID enseignant qui a fait l'override + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (id_evaluation) REFERENCES evaluations(id_evaluation) ON DELETE CASCADE, + FOREIGN KEY (id_eleve) REFERENCES utilisateurs(id_utilisateur) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES utilisateurs(id_utilisateur) ON DELETE CASCADE, + UNIQUE KEY unique_student_eval (id_evaluation, id_eleve), + INDEX idx_evaluation (id_evaluation), + INDEX idx_eleve (id_eleve) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Pré-remplir les modèles disponibles +INSERT INTO available_models +(provider, model_id, display_name, description, context_length, is_free, cost_per_1m_tokens, recommended, speed_rating, quality_rating) +VALUES +-- Gemini (Google) +('gemini', 'gemini-2.0-flash-exp', 'Gemini 2.0 Flash (Expérimental)', 'Modèle ultra-rapide de Google, gratuit avec quotas généreux', 1000000, TRUE, 0, TRUE, 5, 4), +('gemini', 'gemini-1.5-flash', 'Gemini 1.5 Flash', 'Modèle rapide et équilibré', 1000000, TRUE, 0, FALSE, 4, 4), +('gemini', 'gemini-1.5-pro', 'Gemini 1.5 Pro', 'Modèle le plus puissant de Google', 2000000, FALSE, 1.25, FALSE, 3, 5), + +-- Mistral +('mistral', 'mistral-small-latest', 'Mistral Small', 'Modèle compact et rapide', 32000, FALSE, 0.20, FALSE, 5, 3), +('mistral', 'ministral-3b-latest', 'Ministral 3B', 'Petit modèle efficace pour le raisonnement', 128000, FALSE, 0.04, TRUE, 5, 3), +('mistral', 'ministral-8b-latest', 'Ministral 8B', 'Meilleur équilibre qualité/vitesse', 128000, FALSE, 0.10, TRUE, 4, 4), +('mistral', 'mistral-large-latest', 'Mistral Large', 'Modèle le plus puissant de Mistral', 128000, FALSE, 2.00, FALSE, 3, 5), + +-- OpenAI +('openai', 'gpt-4o-mini', 'GPT-4o Mini', 'Petit modèle rapide et économique', 128000, FALSE, 0.15, TRUE, 5, 4), +('openai', 'gpt-4o', 'GPT-4o', 'Modèle multimodal puissant', 128000, FALSE, 2.50, FALSE, 4, 5), +('openai', 'gpt-4-turbo', 'GPT-4 Turbo', 'Modèle GPT-4 optimisé', 128000, FALSE, 10.00, FALSE, 3, 5), + +-- Ollama (local) +('ollama', 'ministral-3:3b', 'Ministral 3B (Local)', 'Modèle local rapide', 128000, TRUE, 0, TRUE, 4, 3), +('ollama', 'qwen2-math:1.5b', 'Qwen2 Math 1.5B (Local)', 'Spécialisé en mathématiques', 32000, TRUE, 0, FALSE, 5, 3), +('ollama', 'llama3.2:3b', 'Llama 3.2 3B (Local)', 'Modèle généraliste compact', 128000, TRUE, 0, FALSE, 3, 4); + +-- Ajouter colonne provider à la table chat_conversations pour tracking +ALTER TABLE chat_conversations +ADD COLUMN IF NOT EXISTS provider VARCHAR(50) DEFAULT 'ollama', +ADD COLUMN IF NOT EXISTS model_used VARCHAR(100) DEFAULT 'ministral-3:3b', +ADD COLUMN IF NOT EXISTS api_response_time_ms INT DEFAULT NULL, +ADD INDEX idx_provider (provider); + +-- Ajouter colonne provider à la table chat_messages pour analytics +ALTER TABLE chat_messages +ADD COLUMN IF NOT EXISTS api_cost DECIMAL(10,6) DEFAULT 0.000000; + +-- Vue pour les stats d'utilisation enseignant +CREATE OR REPLACE VIEW v_chatbot_usage_stats AS +SELECT + cc.id_enseignant, + cc.provider, + cc.model_used, + COUNT(DISTINCT cc.id_conversation) as total_conversations, + COUNT(cm.id) as total_messages, + AVG(cc.api_response_time_ms) as avg_response_time_ms, + SUM(cm.api_cost) as total_cost, + DATE_FORMAT(cc.date_debut, '%Y-%m') as month +FROM chat_conversations cc +LEFT JOIN chat_messages cm ON cc.id_conversation = cm.id_conversation +GROUP BY cc.id_enseignant, cc.provider, cc.model_used, month; + +-- ============================================ +-- FIN DU SCRIPT +-- ============================================ diff --git a/sql/update_chat_tables.sql b/sql/update_chat_tables.sql new file mode 100644 index 0000000..2845b9c --- /dev/null +++ b/sql/update_chat_tables.sql @@ -0,0 +1,109 @@ +-- ============================================ +-- WebVal - Mise à jour chat_conversations +-- Ajout colonnes pour tracking API (sécurisé) +-- ============================================ + +-- Désactiver les erreurs temporairement +SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0; + +-- Ajouter colonnes une par une (ignore si existe déjà) +SET @s = (SELECT IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_schema = DATABASE() + AND table_name = 'chat_conversations' + AND column_name = 'provider') > 0, + 'SELECT ''Column provider already exists''', + 'ALTER TABLE chat_conversations ADD COLUMN provider VARCHAR(50) DEFAULT ''ollama''' +)); +PREPARE stmt FROM @s; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @s = (SELECT IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_schema = DATABASE() + AND table_name = 'chat_conversations' + AND column_name = 'model_used') > 0, + 'SELECT ''Column model_used already exists''', + 'ALTER TABLE chat_conversations ADD COLUMN model_used VARCHAR(100) DEFAULT ''ministral-3:3b''' +)); +PREPARE stmt FROM @s; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @s = (SELECT IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_schema = DATABASE() + AND table_name = 'chat_conversations' + AND column_name = 'api_response_time_ms') > 0, + 'SELECT ''Column api_response_time_ms already exists''', + 'ALTER TABLE chat_conversations ADD COLUMN api_response_time_ms INT DEFAULT NULL' +)); +PREPARE stmt FROM @s; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @s = (SELECT IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_schema = DATABASE() + AND table_name = 'chat_conversations' + AND column_name = 'id_enseignant') > 0, + 'SELECT ''Column id_enseignant already exists''', + 'ALTER TABLE chat_conversations ADD COLUMN id_enseignant INT DEFAULT NULL' +)); +PREPARE stmt FROM @s; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Index +SET @s = (SELECT IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE table_schema = DATABASE() + AND table_name = 'chat_conversations' + AND index_name = 'idx_provider') > 0, + 'SELECT ''Index idx_provider already exists''', + 'ALTER TABLE chat_conversations ADD INDEX idx_provider (provider)' +)); +PREPARE stmt FROM @s; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Foreign key (plus complexe, on teste avec SHOW CREATE TABLE) +SET @fk_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE + WHERE table_schema = DATABASE() + AND table_name = 'chat_conversations' + AND referenced_table_name = 'utilisateurs' + AND column_name = 'id_enseignant'); + +SET @s = IF(@fk_exists > 0, + 'SELECT ''Foreign key already exists''', + 'ALTER TABLE chat_conversations ADD FOREIGN KEY (id_enseignant) REFERENCES utilisateurs(id_utilisateur) ON DELETE SET NULL' +); +PREPARE stmt FROM @s; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Colonne api_cost dans chat_messages +SET @s = (SELECT IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_schema = DATABASE() + AND table_name = 'chat_messages' + AND column_name = 'api_cost') > 0, + 'SELECT ''Column api_cost already exists''', + 'ALTER TABLE chat_messages ADD COLUMN api_cost DECIMAL(10,6) DEFAULT 0.000000' +)); +PREPARE stmt FROM @s; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Restaurer les notes SQL +SET SQL_NOTES=@OLD_SQL_NOTES; + +-- Remplir id_enseignant rétroactivement +UPDATE chat_conversations cc +JOIN tentatives_eleves te ON cc.id_tentative = te.id_tentative +JOIN evaluations e ON te.id_evaluation = e.id_evaluation +SET cc.id_enseignant = e.id_enseignant +WHERE cc.id_enseignant IS NULL; + +SELECT '✅ Colonnes ajoutées avec succès' as Status;