Spaces:
Runtime error
Runtime error
| {% extends "base.html" %} | |
| {% block title %}Dashboard - Rating Predictor{% endblock %} | |
| {% block nav_items %} | |
| <div class="flex items-center space-x-4"> | |
| <span class="text-gray-700 dark:text-gray-300" id="username-display"> | |
| <i class="fas fa-user mr-2"></i><span id="current-username"></span> | |
| </span> | |
| <button | |
| onclick="logout()" | |
| class="bg-red-500 text-white px-4 py-2 rounded-lg hover:bg-red-600 transition" | |
| > | |
| <i class="fas fa-sign-out-alt mr-2"></i>Logout | |
| </button> | |
| </div> | |
| {% endblock %} | |
| {% block content %} | |
| <div class="max-w-7xl mx-auto"> | |
| <!-- Welcome Section --> | |
| <div class="bg-white dark:bg-slate-800 rounded-2xl shadow-lg p-6 mb-8 fade-in"> | |
| <h2 class="text-3xl font-bold text-gray-800 dark:text-gray-100 mb-2"> | |
| <i class="fas fa-chart-line text-indigo-600 mr-3"></i> | |
| Prediction Dashboard | |
| </h2> | |
| <p class="text-gray-600 dark:text-gray-400">Dự đoán đánh giá sản phẩm từ bình luận tiếng Việt với AI giải thích</p> | |
| <div class="flex flex-wrap gap-2 mt-4"> | |
| <span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"> | |
| <i class="fas fa-brain mr-1"></i>AI Explainability | |
| </span> | |
| <span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"> | |
| <i class="fas fa-highlighter mr-1"></i>Keyword Highlighting | |
| </span> | |
| <span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200"> | |
| <i class="fas fa-chart-bar mr-1"></i>N-gram Analysis | |
| </span> | |
| </div> | |
| </div> | |
| <!-- Input Mode Tabs --> | |
| <div class="bg-white dark:bg-slate-800 rounded-2xl shadow-lg p-6 mb-8"> | |
| <div class="flex space-x-4 mb-6 border-b dark:border-slate-700"> | |
| <button | |
| onclick="switchTab('single')" | |
| id="tab-single" | |
| class="tab-button px-6 py-3 font-medium border-b-2 border-indigo-600 text-indigo-600" | |
| > | |
| <i class="fas fa-comment mr-2"></i>Single Comment | |
| </button> | |
| <button | |
| onclick="switchTab('batch')" | |
| id="tab-batch" | |
| class="tab-button px-6 py-3 font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300" | |
| > | |
| <i class="fas fa-file-csv mr-2"></i>Upload CSV | |
| </button> | |
| </div> | |
| <!-- Single Prediction Form --> | |
| <div id="single-form" class="tab-content"> | |
| <form id="singlePredictionForm"> | |
| <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> | |
| Enter your comment (Vietnamese): | |
| </label> | |
| <textarea | |
| id="single-comment" | |
| rows="4" | |
| class="w-full px-4 py-3 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100" | |
| placeholder="Sản phẩm rất tốt, chất lượng cao..." | |
| ></textarea> | |
| <!-- Explanation Toggle --> | |
| <div class="flex items-center mt-4 mb-4"> | |
| <input | |
| type="checkbox" | |
| id="include-explanation" | |
| class="w-4 h-4 text-indigo-600 bg-gray-100 border-gray-300 rounded focus:ring-indigo-500" | |
| checked | |
| > | |
| <label for="include-explanation" class="ml-2 text-sm font-medium text-gray-700 dark:text-gray-300"> | |
| <i class="fas fa-brain text-indigo-600 mr-1"></i> | |
| Bao gồm giải thích AI (Word Importance) | |
| </label> | |
| </div> | |
| <button | |
| type="submit" | |
| id="single-submit-btn" | |
| class="bg-indigo-600 text-white px-6 py-3 rounded-lg hover:bg-indigo-700 transition font-medium shadow-lg" | |
| > | |
| <i class="fas fa-magic mr-2"></i>Predict Rating | |
| </button> | |
| </form> | |
| <!-- Loading Skeleton for Single Result --> | |
| <div id="single-loading" class="hidden mt-6"> | |
| <div class="p-6 bg-gradient-to-r from-gray-100 to-gray-50 dark:from-slate-700 dark:to-slate-600 rounded-xl animate-pulse"> | |
| <div class="flex items-center space-x-6"> | |
| <div class="w-20 h-20 bg-gray-300 dark:bg-slate-500 rounded-full"></div> | |
| <div class="flex-1"> | |
| <div class="h-6 bg-gray-300 dark:bg-slate-500 rounded w-1/3 mb-3"></div> | |
| <div class="h-4 bg-gray-300 dark:bg-slate-500 rounded w-1/4"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Single Result --> | |
| <div id="single-result" class="hidden mt-6 p-6 bg-gradient-to-r from-green-50 to-blue-50 dark:from-slate-700 dark:to-slate-600 rounded-xl border-2 border-green-200 dark:border-slate-500"> | |
| <h3 class="text-xl font-bold text-gray-800 dark:text-gray-100 mb-4"> | |
| <i class="fas fa-star text-yellow-500 mr-2"></i>Prediction Result | |
| </h3> | |
| <div class="flex items-center space-x-6 mb-4"> | |
| <div class="text-center"> | |
| <div class="text-5xl font-bold text-indigo-600" id="predicted-rating"></div> | |
| <div class="text-sm text-gray-600 dark:text-gray-400 mt-2">Rating</div> | |
| </div> | |
| <div class="text-center"> | |
| <div class="text-3xl font-bold text-green-600" id="confidence-score"></div> | |
| <div class="text-sm text-gray-600 dark:text-gray-400 mt-2">Confidence</div> | |
| </div> | |
| <div class="flex-1"> | |
| <div id="rating-stars" class="text-4xl"></div> | |
| </div> | |
| </div> | |
| <!-- Highlighted Comment --> | |
| <div class="mt-4 p-4 bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-600"> | |
| <h4 class="text-sm font-semibold text-gray-600 dark:text-gray-400 mb-2"> | |
| <i class="fas fa-highlighter mr-1"></i>Comment với Keyword Highlighting: | |
| </h4> | |
| <p id="highlighted-comment" class="text-gray-800 dark:text-gray-200 leading-relaxed"></p> | |
| </div> | |
| <!-- Explanation Section --> | |
| <div id="explanation-section" class="mt-4"></div> | |
| <!-- Keywords Found --> | |
| <div id="keywords-section" class="hidden mt-4 p-4 bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-600"> | |
| <h4 class="text-sm font-semibold text-gray-600 dark:text-gray-400 mb-2"> | |
| <i class="fas fa-tags mr-1"></i>Keywords Found: | |
| </h4> | |
| <div class="flex flex-wrap gap-2"> | |
| <div id="positive-keywords"></div> | |
| <div id="negative-keywords"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Batch Prediction Form --> | |
| <div id="batch-form" class="tab-content hidden"> | |
| <form id="batchPredictionForm"> | |
| <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> | |
| <i class="fas fa-tag mr-2"></i>Product/Item Name (optional): | |
| </label> | |
| <input | |
| type="text" | |
| id="batch-product-name" | |
| class="w-full px-4 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition mb-4 bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100" | |
| placeholder="e.g., iPhone 15, Laptop, Shoes..." | |
| > | |
| <div class="border-2 border-dashed border-gray-300 dark:border-slate-600 rounded-lg p-8 text-center hover:border-indigo-500 transition"> | |
| <i class="fas fa-cloud-upload-alt text-5xl text-gray-400 mb-4"></i> | |
| <label for="csv-file" class="block text-lg font-medium text-gray-700 dark:text-gray-300 mb-2 cursor-pointer"> | |
| Upload CSV File | |
| </label> | |
| <input | |
| type="file" | |
| id="csv-file" | |
| accept=".csv" | |
| class="hidden" | |
| onchange="displayFileName(this)" | |
| > | |
| <p class="text-sm text-gray-500 dark:text-gray-400 mb-2">CSV must contain a "Comment" column</p> | |
| <p id="file-name" class="text-sm font-medium text-indigo-600"></p> | |
| <label for="csv-file" class="inline-block mt-4 bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 cursor-pointer transition"> | |
| Choose File | |
| </label> | |
| </div> | |
| <button | |
| type="submit" | |
| id="batch-submit-btn" | |
| class="mt-6 bg-indigo-600 text-white px-6 py-3 rounded-lg hover:bg-indigo-700 transition font-medium shadow-lg" | |
| > | |
| <i class="fas fa-magic mr-2"></i>Predict Batch | |
| </button> | |
| </form> | |
| <!-- Loading Skeleton for Batch --> | |
| <div id="batch-loading" class="hidden mt-8"> | |
| <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6"> | |
| <div class="bg-white dark:bg-slate-700 p-6 rounded-xl shadow animate-pulse"> | |
| <div class="h-6 bg-gray-300 dark:bg-slate-500 rounded w-1/3 mb-4"></div> | |
| <div class="h-48 bg-gray-300 dark:bg-slate-500 rounded"></div> | |
| </div> | |
| <div class="bg-white dark:bg-slate-700 p-6 rounded-xl shadow animate-pulse"> | |
| <div class="h-6 bg-gray-300 dark:bg-slate-500 rounded w-1/3 mb-4"></div> | |
| <div class="h-48 bg-gray-300 dark:bg-slate-500 rounded"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Batch Results --> | |
| <div id="batch-results" class="hidden mt-8"> | |
| <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6"> | |
| <!-- Rating Distribution Chart --> | |
| <div class="bg-white dark:bg-slate-700 p-6 rounded-xl shadow"> | |
| <h3 class="text-lg font-bold text-gray-800 dark:text-gray-100 mb-4"> | |
| <i class="fas fa-chart-pie text-indigo-600 mr-2"></i>Rating Distribution | |
| </h3> | |
| <canvas id="ratingChart"></canvas> | |
| </div> | |
| <!-- Word Cloud --> | |
| <div class="bg-white dark:bg-slate-700 p-6 rounded-xl shadow"> | |
| <h3 class="text-lg font-bold text-gray-800 dark:text-gray-100 mb-4"> | |
| <i class="fas fa-cloud text-indigo-600 mr-2"></i>Word Cloud | |
| </h3> | |
| <img id="wordcloud-image" src="" alt="Word Cloud" class="w-full rounded-lg"> | |
| </div> | |
| </div> | |
| <!-- N-gram Analysis Section --> | |
| <div class="bg-white dark:bg-slate-700 p-6 rounded-xl shadow mb-6"> | |
| <h3 class="text-lg font-bold text-gray-800 dark:text-gray-100 mb-4"> | |
| <i class="fas fa-chart-bar text-indigo-600 mr-2"></i>N-gram Analysis | |
| </h3> | |
| <div class="grid grid-cols-1 md:grid-cols-3 gap-4"> | |
| <!-- Unigrams --> | |
| <div> | |
| <h4 class="text-sm font-semibold text-gray-600 dark:text-gray-400 mb-2">Top Unigrams (1-word)</h4> | |
| <div id="unigrams-container" class="ngram-container"></div> | |
| </div> | |
| <!-- Bigrams --> | |
| <div> | |
| <h4 class="text-sm font-semibold text-gray-600 dark:text-gray-400 mb-2">Top Bigrams (2-word)</h4> | |
| <div id="bigrams-container" class="ngram-container"></div> | |
| </div> | |
| <!-- Trigrams --> | |
| <div> | |
| <h4 class="text-sm font-semibold text-gray-600 dark:text-gray-400 mb-2">Top Trigrams (3-word)</h4> | |
| <div id="trigrams-container" class="ngram-container"></div> | |
| </div> | |
| </div> | |
| <!-- N-gram Chart --> | |
| <div class="ngram-chart-container mt-6"> | |
| <canvas id="ngramChart"></canvas> | |
| </div> | |
| </div> | |
| <!-- Keyword Frequency Section --> | |
| <div class="bg-white dark:bg-slate-700 p-6 rounded-xl shadow mb-6"> | |
| <h3 class="text-lg font-bold text-gray-800 dark:text-gray-100 mb-4"> | |
| <i class="fas fa-tags text-indigo-600 mr-2"></i>Keyword Frequency | |
| </h3> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| <div> | |
| <h4 class="text-sm font-semibold text-green-600 dark:text-green-400 mb-2"> | |
| <i class="fas fa-smile mr-1"></i>Positive Keywords | |
| </h4> | |
| <div id="positive-freq-container" class="flex flex-wrap gap-2"></div> | |
| </div> | |
| <div> | |
| <h4 class="text-sm font-semibold text-red-600 dark:text-red-400 mb-2"> | |
| <i class="fas fa-frown mr-1"></i>Negative Keywords | |
| </h4> | |
| <div id="negative-freq-container" class="flex flex-wrap gap-2"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Results Table --> | |
| <div class="bg-white dark:bg-slate-700 p-6 rounded-xl shadow"> | |
| <div class="flex justify-between items-center mb-4"> | |
| <h3 class="text-lg font-bold text-gray-800 dark:text-gray-100"> | |
| <i class="fas fa-table text-indigo-600 mr-2"></i>Prediction Results | |
| </h3> | |
| <div class="space-x-3"> | |
| <button | |
| onclick="downloadPDF()" | |
| class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition" | |
| > | |
| <i class="fas fa-file-pdf mr-2"></i>Download PDF | |
| </button> | |
| <button | |
| onclick="downloadCSV()" | |
| class="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition" | |
| > | |
| <i class="fas fa-download mr-2"></i>Download CSV | |
| </button> | |
| </div> | |
| </div> | |
| <div class="overflow-x-auto"> | |
| <table class="w-full" id="results-table"> | |
| <thead class="bg-gray-100 dark:bg-slate-600"> | |
| <tr> | |
| <th class="px-4 py-3 text-left text-sm font-semibold text-gray-700 dark:text-gray-200">Comment</th> | |
| <th class="px-4 py-3 text-center text-sm font-semibold text-gray-700 dark:text-gray-200">Rating</th> | |
| <th class="px-4 py-3 text-center text-sm font-semibold text-gray-700 dark:text-gray-200">Confidence</th> | |
| </tr> | |
| </thead> | |
| <tbody id="results-tbody" class="divide-y divide-gray-200 dark:divide-slate-600"> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- History Section --> | |
| <div class="bg-white dark:bg-slate-800 rounded-2xl shadow-lg p-6 mb-8"> | |
| <div class="flex justify-between items-center mb-6"> | |
| <h2 class="text-2xl font-bold text-gray-800 dark:text-gray-100"> | |
| <i class="fas fa-history text-indigo-600 mr-2"></i>Prediction History | |
| </h2> | |
| <button | |
| onclick="refreshHistory()" | |
| class="bg-indigo-600 text-white px-4 py-2 rounded-lg hover:bg-indigo-700 transition font-medium" | |
| > | |
| <i class="fas fa-sync-alt mr-2"></i>Refresh | |
| </button> | |
| </div> | |
| <!-- History Loading Skeleton --> | |
| <div id="history-loading" class="hidden"> | |
| <div class="space-y-3"> | |
| <div class="h-12 bg-gray-200 dark:bg-slate-600 rounded animate-pulse"></div> | |
| <div class="h-12 bg-gray-200 dark:bg-slate-600 rounded animate-pulse"></div> | |
| <div class="h-12 bg-gray-200 dark:bg-slate-600 rounded animate-pulse"></div> | |
| </div> | |
| </div> | |
| <div class="overflow-x-auto"> | |
| <table class="w-full" id="history-table"> | |
| <thead class="bg-gray-100 dark:bg-slate-700"> | |
| <tr> | |
| <th class="px-4 py-3 text-left text-sm font-semibold text-gray-700 dark:text-gray-200">Date/Time</th> | |
| <th class="px-4 py-3 text-left text-sm font-semibold text-gray-700 dark:text-gray-200">Comment</th> | |
| <th class="px-4 py-3 text-center text-sm font-semibold text-gray-700 dark:text-gray-200">Rating</th> | |
| <th class="px-4 py-3 text-center text-sm font-semibold text-gray-700 dark:text-gray-200">Confidence</th> | |
| <th class="px-4 py-3 text-center text-sm font-semibold text-gray-700 dark:text-gray-200">Type</th> | |
| </tr> | |
| </thead> | |
| <tbody id="history-tbody" class="divide-y divide-gray-200 dark:divide-slate-600"> | |
| <tr class="text-center text-gray-500 dark:text-gray-400 py-8"> | |
| <td colspan="6" class="px-4 py-8">Loading history...</td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| {% endblock %} | |
| {% block scripts %} | |
| <script> | |
| // Check authentication | |
| const token = localStorage.getItem('access_token'); | |
| const username = localStorage.getItem('username'); | |
| if (!token) { | |
| window.location.href = '/login'; | |
| } | |
| document.getElementById('current-username').textContent = username || 'User'; | |
| // Global variables | |
| let currentResults = []; | |
| let currentDistribution = {}; | |
| let currentWordcloudUrl = ''; | |
| let currentNgrams = null; | |
| let chartInstance = null; | |
| let ngramChartInstance = null; | |
| // Load history on page load | |
| document.addEventListener('DOMContentLoaded', () => { | |
| loadHistory(); | |
| }); | |
| // Logout function | |
| function logout() { | |
| localStorage.removeItem('access_token'); | |
| localStorage.removeItem('username'); | |
| toast.info('Logged Out', 'You have been logged out successfully'); | |
| setTimeout(() => { | |
| window.location.href = '/login'; | |
| }, 1000); | |
| } | |
| // Tab switching | |
| function switchTab(tab) { | |
| const tabs = ['single', 'batch']; | |
| tabs.forEach(t => { | |
| const button = document.getElementById(`tab-${t}`); | |
| const content = document.getElementById(`${t}-form`); | |
| if (t === tab) { | |
| button.classList.add('border-indigo-600', 'text-indigo-600'); | |
| button.classList.remove('text-gray-500', 'dark:text-gray-400'); | |
| content.classList.remove('hidden'); | |
| } else { | |
| button.classList.remove('border-indigo-600', 'text-indigo-600'); | |
| button.classList.add('text-gray-500', 'dark:text-gray-400'); | |
| content.classList.add('hidden'); | |
| } | |
| }); | |
| // Hide results when switching | |
| document.getElementById('single-result').classList.add('hidden'); | |
| document.getElementById('batch-results').classList.add('hidden'); | |
| } | |
| // Display selected file name | |
| function displayFileName(input) { | |
| const fileName = input.files[0]?.name || ''; | |
| document.getElementById('file-name').textContent = fileName ? `Selected: ${fileName}` : ''; | |
| } | |
| // Single Prediction | |
| document.getElementById('singlePredictionForm').addEventListener('submit', async (e) => { | |
| e.preventDefault(); | |
| const comment = document.getElementById('single-comment').value; | |
| const includeExplanation = document.getElementById('include-explanation').checked; | |
| if (!comment.trim()) { | |
| toast.warning('Missing Input', 'Please enter a comment!'); | |
| return; | |
| } | |
| // Show loading | |
| document.getElementById('single-loading').classList.remove('hidden'); | |
| document.getElementById('single-result').classList.add('hidden'); | |
| document.getElementById('single-submit-btn').disabled = true; | |
| document.getElementById('single-submit-btn').innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Predicting...'; | |
| try { | |
| const response = await fetch('/api/predict/single', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${token}` | |
| }, | |
| body: JSON.stringify({ | |
| product_name: '', | |
| comment: comment, | |
| include_explanation: includeExplanation | |
| }) | |
| }); | |
| if (response.ok) { | |
| const data = await response.json(); | |
| displaySingleResult(data); | |
| toast.success('Prediction Complete', `Rating: ${data.predicted_rating}⭐ (${(data.confidence_score * 100).toFixed(1)}% confidence)`); | |
| // Reload history | |
| setTimeout(() => loadHistory(), 500); | |
| } else { | |
| const error = await response.json(); | |
| toast.error('Prediction Failed', error.detail || 'Unknown error'); | |
| } | |
| } catch (error) { | |
| toast.error('Error', 'An error occurred: ' + error.message); | |
| } finally { | |
| // Hide loading | |
| document.getElementById('single-loading').classList.add('hidden'); | |
| document.getElementById('single-submit-btn').disabled = false; | |
| document.getElementById('single-submit-btn').innerHTML = '<i class="fas fa-magic mr-2"></i>Predict Rating'; | |
| } | |
| }); | |
| function displaySingleResult(data) { | |
| document.getElementById('predicted-rating').textContent = data.predicted_rating; | |
| document.getElementById('confidence-score').textContent = (data.confidence_score * 100).toFixed(1) + '%'; | |
| // Display stars | |
| const stars = '⭐'.repeat(data.predicted_rating); | |
| document.getElementById('rating-stars').textContent = stars; | |
| // Display highlighted comment | |
| if (data.highlighted_comment) { | |
| document.getElementById('highlighted-comment').innerHTML = data.highlighted_comment; | |
| } else { | |
| document.getElementById('highlighted-comment').textContent = data.comment; | |
| } | |
| // Display explanation if available | |
| const explanationSection = document.getElementById('explanation-section'); | |
| if (data.explanation) { | |
| ExplanationVisualizer.render('explanation-section', data.explanation); | |
| } else { | |
| explanationSection.innerHTML = ''; | |
| } | |
| // Display keywords if available | |
| const keywordsSection = document.getElementById('keywords-section'); | |
| if (data.keywords) { | |
| const positiveContainer = document.getElementById('positive-keywords'); | |
| const negativeContainer = document.getElementById('negative-keywords'); | |
| if (data.keywords.positive_keywords && data.keywords.positive_keywords.length > 0) { | |
| positiveContainer.innerHTML = data.keywords.positive_keywords.map(kw => | |
| `<span class="inline-block px-2 py-1 m-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">${kw}</span>` | |
| ).join(''); | |
| } else { | |
| positiveContainer.innerHTML = '<span class="text-gray-400 text-sm">None found</span>'; | |
| } | |
| if (data.keywords.negative_keywords && data.keywords.negative_keywords.length > 0) { | |
| negativeContainer.innerHTML = data.keywords.negative_keywords.map(kw => | |
| `<span class="inline-block px-2 py-1 m-1 text-xs rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">${kw}</span>` | |
| ).join(''); | |
| } else { | |
| negativeContainer.innerHTML = '<span class="text-gray-400 text-sm">None found</span>'; | |
| } | |
| keywordsSection.classList.remove('hidden'); | |
| } else { | |
| keywordsSection.classList.add('hidden'); | |
| } | |
| document.getElementById('single-result').classList.remove('hidden'); | |
| } | |
| // Batch Prediction | |
| document.getElementById('batchPredictionForm').addEventListener('submit', async (e) => { | |
| e.preventDefault(); | |
| const productName = document.getElementById('batch-product-name').value || ''; | |
| const fileInput = document.getElementById('csv-file'); | |
| const file = fileInput.files[0]; | |
| if (!file) { | |
| toast.warning('Missing File', 'Please select a CSV file!'); | |
| return; | |
| } | |
| // Show loading | |
| document.getElementById('batch-loading').classList.remove('hidden'); | |
| document.getElementById('batch-results').classList.add('hidden'); | |
| document.getElementById('batch-submit-btn').disabled = true; | |
| document.getElementById('batch-submit-btn').innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Processing...'; | |
| const formData = new FormData(); | |
| formData.append('product_name', productName); | |
| formData.append('file', file); | |
| try { | |
| const response = await fetch('/api/predict/batch', { | |
| method: 'POST', | |
| headers: { | |
| 'Authorization': `Bearer ${token}` | |
| }, | |
| body: formData | |
| }); | |
| if (response.ok) { | |
| const data = await response.json(); | |
| displayBatchResults(data); | |
| toast.success('Batch Complete', `Processed ${data.total_predictions} predictions`); | |
| // Reload history | |
| setTimeout(() => loadHistory(), 500); | |
| } else { | |
| const error = await response.json(); | |
| toast.error('Batch Failed', error.detail || 'Prediction failed'); | |
| } | |
| } catch (error) { | |
| toast.error('Error', 'An error occurred: ' + error.message); | |
| } finally { | |
| // Hide loading | |
| document.getElementById('batch-loading').classList.add('hidden'); | |
| document.getElementById('batch-submit-btn').disabled = false; | |
| document.getElementById('batch-submit-btn').innerHTML = '<i class="fas fa-magic mr-2"></i>Predict Batch'; | |
| } | |
| }); | |
| function displayBatchResults(data) { | |
| currentResults = data.results; | |
| currentDistribution = data.rating_distribution; | |
| currentWordcloudUrl = data.wordcloud_url; | |
| currentNgrams = data.ngrams; | |
| // Display word cloud | |
| document.getElementById('wordcloud-image').src = data.wordcloud_url; | |
| // Create chart | |
| createRatingChart(data.rating_distribution); | |
| // Display N-grams | |
| if (data.ngrams) { | |
| displayNgrams(data.ngrams); | |
| } | |
| // Display keyword frequency | |
| if (data.keyword_frequency) { | |
| displayKeywordFrequency(data.keyword_frequency); | |
| } | |
| // Populate table | |
| const tbody = document.getElementById('results-tbody'); | |
| tbody.innerHTML = ''; | |
| data.results.forEach(result => { | |
| // Apply highlighting to comment | |
| const highlightedComment = highlighter.highlight(result.Comment); | |
| const row = ` | |
| <tr class="hover:bg-gray-50 dark:hover:bg-slate-600"> | |
| <td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300">${highlightedComment}</td> | |
| <td class="px-4 py-3 text-center"> | |
| <span class="inline-block bg-indigo-100 dark:bg-indigo-900 text-indigo-800 dark:text-indigo-200 px-3 py-1 rounded-full font-semibold"> | |
| ${result.Predicted_Rating}⭐ | |
| </span> | |
| </td> | |
| <td class="px-4 py-3 text-center text-sm text-gray-600 dark:text-gray-400"> | |
| ${(result.Confidence * 100).toFixed(1)}% | |
| </td> | |
| </tr> | |
| `; | |
| tbody.innerHTML += row; | |
| }); | |
| document.getElementById('batch-results').classList.remove('hidden'); | |
| } | |
| function displayNgrams(ngrams) { | |
| // Display unigrams | |
| const unigramsHtml = ngrams.unigrams.slice(0, 8).map(item => | |
| `<div class="ngram-badge"><span>${item.ngram}</span><span class="ngram-count">${item.count}</span></div>` | |
| ).join(''); | |
| document.getElementById('unigrams-container').innerHTML = unigramsHtml; | |
| // Display bigrams | |
| const bigramsHtml = ngrams.bigrams.slice(0, 8).map(item => | |
| `<div class="ngram-badge"><span>${item.ngram}</span><span class="ngram-count">${item.count}</span></div>` | |
| ).join(''); | |
| document.getElementById('bigrams-container').innerHTML = bigramsHtml; | |
| // Display trigrams | |
| const trigramsHtml = ngrams.trigrams.slice(0, 6).map(item => | |
| `<div class="ngram-badge"><span>${item.ngram}</span><span class="ngram-count">${item.count}</span></div>` | |
| ).join(''); | |
| document.getElementById('trigrams-container').innerHTML = trigramsHtml; | |
| // Create N-gram chart (bigrams) | |
| createNgramChart(ngrams.bigrams); | |
| } | |
| function displayKeywordFrequency(keywordFreq) { | |
| // Positive keywords | |
| const positiveHtml = keywordFreq.positive.map(item => | |
| `<span class="inline-flex items-center px-3 py-1 rounded-full text-sm bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200"> | |
| ${item.word} <span class="ml-1 bg-green-200 dark:bg-green-800 px-2 py-0.5 rounded-full text-xs">${item.count}</span> | |
| </span>` | |
| ).join(''); | |
| document.getElementById('positive-freq-container').innerHTML = positiveHtml || '<span class="text-gray-400">No positive keywords found</span>'; | |
| // Negative keywords | |
| const negativeHtml = keywordFreq.negative.map(item => | |
| `<span class="inline-flex items-center px-3 py-1 rounded-full text-sm bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200"> | |
| ${item.word} <span class="ml-1 bg-red-200 dark:bg-red-800 px-2 py-0.5 rounded-full text-xs">${item.count}</span> | |
| </span>` | |
| ).join(''); | |
| document.getElementById('negative-freq-container').innerHTML = negativeHtml || '<span class="text-gray-400">No negative keywords found</span>'; | |
| } | |
| function createNgramChart(bigrams) { | |
| const ctx = document.getElementById('ngramChart').getContext('2d'); | |
| const isDark = document.documentElement.classList.contains('dark'); | |
| // Destroy existing chart | |
| if (ngramChartInstance) { | |
| ngramChartInstance.destroy(); | |
| } | |
| const topBigrams = bigrams.slice(0, 10); | |
| ngramChartInstance = new Chart(ctx, { | |
| type: 'bar', | |
| data: { | |
| labels: topBigrams.map(item => item.ngram), | |
| datasets: [{ | |
| label: 'Frequency', | |
| data: topBigrams.map(item => item.count), | |
| backgroundColor: topBigrams.map((_, i) => { | |
| const hue = 250 + (i * 15); | |
| return `hsla(${hue}, 70%, 60%, 0.8)`; | |
| }), | |
| borderColor: topBigrams.map((_, i) => { | |
| const hue = 250 + (i * 15); | |
| return `hsla(${hue}, 70%, 50%, 1)`; | |
| }), | |
| borderWidth: 2 | |
| }] | |
| }, | |
| options: { | |
| indexAxis: 'y', | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| plugins: { | |
| legend: { | |
| display: false | |
| }, | |
| title: { | |
| display: true, | |
| text: 'Top Bigrams Frequency', | |
| color: isDark ? '#e2e8f0' : '#1f2937' | |
| } | |
| }, | |
| scales: { | |
| x: { | |
| beginAtZero: true, | |
| grid: { | |
| color: isDark ? '#334155' : '#e5e7eb' | |
| }, | |
| ticks: { | |
| color: isDark ? '#94a3b8' : '#4b5563' | |
| } | |
| }, | |
| y: { | |
| grid: { | |
| display: false | |
| }, | |
| ticks: { | |
| color: isDark ? '#94a3b8' : '#4b5563' | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| function createRatingChart(distribution) { | |
| const ctx = document.getElementById('ratingChart').getContext('2d'); | |
| const isDark = document.documentElement.classList.contains('dark'); | |
| // Destroy existing chart | |
| if (chartInstance) { | |
| chartInstance.destroy(); | |
| } | |
| chartInstance = new Chart(ctx, { | |
| type: 'bar', | |
| data: { | |
| labels: ['1⭐', '2⭐', '3⭐', '4⭐', '5⭐'], | |
| datasets: [{ | |
| label: 'Number of Reviews', | |
| data: [ | |
| distribution[1] || 0, | |
| distribution[2] || 0, | |
| distribution[3] || 0, | |
| distribution[4] || 0, | |
| distribution[5] || 0 | |
| ], | |
| backgroundColor: [ | |
| 'rgba(239, 68, 68, 0.8)', | |
| 'rgba(251, 146, 60, 0.8)', | |
| 'rgba(250, 204, 21, 0.8)', | |
| 'rgba(132, 204, 22, 0.8)', | |
| 'rgba(34, 197, 94, 0.8)' | |
| ], | |
| borderColor: [ | |
| 'rgba(239, 68, 68, 1)', | |
| 'rgba(251, 146, 60, 1)', | |
| 'rgba(250, 204, 21, 1)', | |
| 'rgba(132, 204, 22, 1)', | |
| 'rgba(34, 197, 94, 1)' | |
| ], | |
| borderWidth: 2 | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: true, | |
| plugins: { | |
| legend: { | |
| display: false | |
| } | |
| }, | |
| scales: { | |
| y: { | |
| beginAtZero: true, | |
| ticks: { | |
| stepSize: 1, | |
| color: isDark ? '#94a3b8' : '#4b5563' | |
| }, | |
| grid: { | |
| color: isDark ? '#334155' : '#e5e7eb' | |
| } | |
| }, | |
| x: { | |
| ticks: { | |
| color: isDark ? '#94a3b8' : '#4b5563' | |
| }, | |
| grid: { | |
| color: isDark ? '#334155' : '#e5e7eb' | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| function downloadCSV() { | |
| if (currentResults.length === 0) { | |
| toast.warning('No Data', 'No results to download'); | |
| return; | |
| } | |
| // Create CSV content | |
| const headers = ['Comment', 'Predicted_Rating', 'Confidence']; | |
| const csvContent = [ | |
| headers.join(','), | |
| ...currentResults.map(r => | |
| `"${r.Comment.replace(/"/g, '""')}",${r.Predicted_Rating},${r.Confidence}` | |
| ) | |
| ].join('\n'); | |
| // Create download link | |
| const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); | |
| const link = document.createElement('a'); | |
| const url = URL.createObjectURL(blob); | |
| link.setAttribute('href', url); | |
| link.setAttribute('download', `predictions_${new Date().getTime()}.csv`); | |
| link.style.visibility = 'hidden'; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| toast.success('Downloaded', 'CSV file downloaded successfully'); | |
| } | |
| function downloadPDF() { | |
| if (currentResults.length === 0) { | |
| toast.warning('No Data', 'No results to download'); | |
| return; | |
| } | |
| toast.info('Generating PDF', 'Please wait...'); | |
| try { | |
| // Prepare data | |
| const predictions = currentResults.map(r => ({ | |
| text: r.Comment, | |
| rating: r.Predicted_Rating, | |
| confidence: r.Confidence | |
| })); | |
| // Send request to generate PDF | |
| fetch('/api/predict/download-pdf', { | |
| method: 'POST', | |
| headers: { | |
| 'Authorization': `Bearer ${token}`, | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({ | |
| predictions: predictions, | |
| distribution: currentDistribution, | |
| wordcloud_path: currentWordcloudUrl | |
| }) | |
| }) | |
| .then(response => { | |
| if (response.ok) { | |
| return response.blob(); | |
| } | |
| throw new Error('Failed to generate PDF'); | |
| }) | |
| .then(blob => { | |
| const url = URL.createObjectURL(blob); | |
| const link = document.createElement('a'); | |
| link.href = url; | |
| link.download = `predictions_report_${new Date().getTime()}.pdf`; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| URL.revokeObjectURL(url); | |
| toast.success('Downloaded', 'PDF report downloaded successfully'); | |
| }) | |
| .catch(error => { | |
| console.error('Error downloading PDF:', error); | |
| toast.error('Error', 'Error generating PDF report. Please try again.'); | |
| }); | |
| } catch (error) { | |
| console.error('Error preparing PDF download:', error); | |
| toast.error('Error', 'Error preparing PDF report'); | |
| } | |
| } | |
| // Load and display prediction history | |
| async function loadHistory() { | |
| document.getElementById('history-loading').classList.remove('hidden'); | |
| try { | |
| const response = await fetch('/api/predict/history', { | |
| headers: { | |
| 'Authorization': `Bearer ${token}` | |
| } | |
| }); | |
| if (response.ok) { | |
| const history = await response.json(); | |
| displayHistory(history); | |
| } else { | |
| console.error('Failed to load history'); | |
| } | |
| } catch (error) { | |
| console.error('Error loading history:', error); | |
| } finally { | |
| document.getElementById('history-loading').classList.add('hidden'); | |
| } | |
| } | |
| function displayHistory(history) { | |
| const tbody = document.getElementById('history-tbody'); | |
| if (history.length === 0) { | |
| tbody.innerHTML = ` | |
| <tr class="text-center text-gray-500 dark:text-gray-400"> | |
| <td colspan="5" class="px-4 py-8"> | |
| <i class="fas fa-inbox text-3xl text-gray-300 dark:text-gray-600 mb-2"></i> | |
| <p>No prediction history yet</p> | |
| </td> | |
| </tr> | |
| `; | |
| return; | |
| } | |
| tbody.innerHTML = ''; | |
| history.forEach(item => { | |
| const date = new Date(item.created_at).toLocaleString('vi-VN'); | |
| const shortComment = item.comment.length > 50 | |
| ? item.comment.substring(0, 50) + '...' | |
| : item.comment; | |
| // Apply highlighting | |
| const highlightedComment = highlighter.highlight(shortComment); | |
| const row = ` | |
| <tr class="hover:bg-gray-50 dark:hover:bg-slate-700"> | |
| <td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">${date}</td> | |
| <td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-300" title="${item.comment}">${highlightedComment}</td> | |
| <td class="px-4 py-3 text-center"> | |
| <span class="inline-block bg-indigo-100 dark:bg-indigo-900 text-indigo-800 dark:text-indigo-200 px-3 py-1 rounded-full font-semibold text-sm"> | |
| ${item.predicted_rating}⭐ | |
| </span> | |
| </td> | |
| <td class="px-4 py-3 text-center text-sm text-gray-600 dark:text-gray-400"> | |
| ${(item.confidence_score * 100).toFixed(1)}% | |
| </td> | |
| <td class="px-4 py-3 text-center text-sm"> | |
| <span class="inline-block ${item.prediction_type === 'single' ? 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200' : 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'} px-2 py-1 rounded text-xs font-semibold"> | |
| ${item.prediction_type} | |
| </span> | |
| </td> | |
| </tr> | |
| `; | |
| tbody.innerHTML += row; | |
| }); | |
| } | |
| function refreshHistory() { | |
| toast.info('Refreshing', 'Loading history...'); | |
| loadHistory(); | |
| } | |
| </script> | |
| {% endblock %} | |