manhteky123 commited on
Commit
244aaa1
·
0 Parent(s):

Initial commit: Vietnamese Sentiment Analysis App with Flask and Transformers

Browse files
.dockerignore ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ *.egg-info/
8
+ dist/
9
+ build/
10
+
11
+ # Virtual environment
12
+ venv/
13
+ env/
14
+ ENV/
15
+ .venv
16
+
17
+ # IDE
18
+ .vscode/
19
+ .idea/
20
+ *.swp
21
+ *.swo
22
+
23
+ # OS
24
+ .DS_Store
25
+ Thumbs.db
26
+
27
+ # Database (sẽ tạo mới trên server)
28
+ *.db
29
+ *.sqlite
30
+ *.sqlite3
31
+
32
+ # Git
33
+ .git/
34
+ .gitignore
35
+
36
+ # Test results
37
+ test_results.txt
38
+
39
+ # Documentation
40
+ TEST_CASES.md
41
+ CHECKLIST_DEBAI.md
42
+ debai.md
43
+
44
+ # Model cache (sẽ tải lại trên server)
45
+ transformers_cache/
46
+ .cache/
47
+
48
+ # Logs
49
+ *.log
50
+
51
+ # Other
52
+ .env
53
+ .env.local
Dockerfile ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Sử dụng Python 3.11 slim image
2
+ FROM python:3.11-slim
3
+
4
+ # Set working directory
5
+ WORKDIR /app
6
+
7
+ # Set environment variables
8
+ ENV PYTHONUNBUFFERED=1 \
9
+ PYTHONDONTWRITEBYTECODE=1 \
10
+ TF_CPP_MIN_LOG_LEVEL=3 \
11
+ TRANSFORMERS_NO_TF=1 \
12
+ PORT=7860
13
+
14
+ # Install system dependencies
15
+ RUN apt-get update && apt-get install -y \
16
+ build-essential \
17
+ curl \
18
+ && rm -rf /var/lib/apt/lists/*
19
+
20
+ # Copy requirements first for better caching
21
+ COPY requirements.txt .
22
+
23
+ # Install Python dependencies
24
+ RUN pip install --no-cache-dir -r requirements.txt
25
+
26
+ # Copy application code
27
+ COPY . .
28
+
29
+ # Create directory for database
30
+ RUN mkdir -p /app/data
31
+
32
+ # Expose port for Hugging Face Spaces
33
+ EXPOSE 7860
34
+
35
+ # Health check
36
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
37
+ CMD curl -f http://localhost:7860/ || exit 1
38
+
39
+ # Run the application
40
+ CMD ["python", "run.py"]
README.md ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Vietnamese Sentiment Analysis
3
+ emoji: 🤖
4
+ colorFrom: purple
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: mit
10
+ ---
11
+
12
+ # 🤖 Trợ lý Phân loại Cảm xúc Tiếng Việt
13
+
14
+ Ứng dụng web phân loại cảm xúc (tích cực, trung tính, tiêu cực) từ văn bản tiếng Việt sử dụng Transformer.
15
+
16
+ ## ✨ Tính năng
17
+
18
+ - ✅ Phân loại cảm xúc tiếng Việt (POSITIVE, NEUTRAL, NEGATIVE)
19
+ - ✅ Hỗ trợ văn bản viết tắt và thiếu dấu
20
+ - ✅ Lưu trữ lịch sử phân loại
21
+ - ✅ Giao diện web thân thiện, responsive
22
+ - ✅ Độ chính xác **90%** trên test cases
23
+
24
+ ## 🛠 Công nghệ
25
+
26
+ - **Model**: XLM-RoBERTa (`cardiffnlp/twitter-xlm-roberta-base-sentiment`)
27
+ - **Framework**: Flask + Hugging Face Transformers
28
+ - **Pipeline**: sentiment-analysis (pre-trained, no fine-tuning needed)
29
+ - **Database**: SQLite
30
+ - **Text Processing**: Underthesea (Vietnamese tokenization)
31
+
32
+ ## 🚀 Cách sử dụng
33
+
34
+ 1. **Nhập câu tiếng Việt** vào ô văn bản
35
+ 2. **Nhấn nút "Phân loại Cảm xúc"**
36
+ 3. **Xem kết quả** với nhãn cảm xúc và độ tin cậy
37
+ 4. **Xem lịch sử** các câu đã phân loại
38
+
39
+ ## 📝 Ví dụ
40
+
41
+ | Câu tiếng Việt | Kết quả |
42
+ |----------------|---------|
43
+ | "Hôm nay tôi rất vui" | 😊 POSITIVE |
44
+ | "Món ăn này dở quá" | 😞 NEGATIVE |
45
+ | "Thời tiết bình thường" | 😐 NEUTRAL |
46
+ | "Phim này hay lắm" | 😊 POSITIVE |
47
+ | "Tôi buồn vì thất bại" | 😞 NEGATIVE |
48
+
49
+ ## 🎯 Kiến trúc
50
+
51
+ Ứng dụng theo kiến trúc 3 components:
52
+
53
+ 1. **Component 1 - Tiền xử lý**:
54
+ - Chuẩn hóa văn bản tiếng Việt
55
+ - Xử lý từ viết tắt (rat → rất, ko → không...)
56
+ - Underthesea tokenization (optional)
57
+
58
+ 2. **Component 2 - Phân loại**:
59
+ - Transformers pipeline `sentiment-analysis`
60
+ - Model: XLM-RoBERTa multilingual
61
+
62
+ 3. **Component 3 - Post-processing**:
63
+ - Mapping nhãn về 3 loại (POSITIVE/NEUTRAL/NEGATIVE)
64
+ - Validation và lưu database
65
+
66
+ ## 📊 Hiệu suất
67
+
68
+ - **Độ chính xác**: 90% (9/10 test cases)
69
+ - **Model size**: ~1.1GB
70
+ - **Inference time**: ~1-2s per sentence
71
+
72
+ ## 🎓 Dự án
73
+
74
+ Đồ án môn học **Chuyên đề - Phân loại Cảm xúc Tiếng Việt** sử dụng Transformer.
75
+
76
+ ## 📄 License
77
+
78
+ MIT License - Dự án học tập
79
+
80
+ ---
81
+
82
+ **Phát triển bởi**: Sinh viên Chuyên đề NLP
app/__init__.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Flask
2
+ import os
3
+
4
+ def create_app():
5
+ app = Flask(__name__)
6
+ app.config['SECRET_KEY'] = 'vietnamese-sentiment-analysis-2025'
7
+ app.config['DATABASE'] = os.path.join(os.path.dirname(__file__), '..', 'sentiments.db')
8
+
9
+ from app import routes
10
+ app.register_blueprint(routes.bp)
11
+
12
+ return app
app/database.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ from datetime import datetime
3
+ from flask import current_app, g
4
+
5
+ def get_db():
6
+ """Kết nối database SQLite"""
7
+ if 'db' not in g:
8
+ g.db = sqlite3.connect(
9
+ current_app.config['DATABASE'],
10
+ detect_types=sqlite3.PARSE_DECLTYPES
11
+ )
12
+ g.db.row_factory = sqlite3.Row
13
+ return g.db
14
+
15
+ def close_db(e=None):
16
+ """Đóng kết nối database"""
17
+ db = g.pop('db', None)
18
+ if db is not None:
19
+ db.close()
20
+
21
+ def init_db():
22
+ """Khởi tạo database"""
23
+ db = get_db()
24
+ db.execute('''
25
+ CREATE TABLE IF NOT EXISTS sentiments (
26
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
27
+ text TEXT NOT NULL,
28
+ sentiment TEXT NOT NULL,
29
+ timestamp TEXT NOT NULL
30
+ )
31
+ ''')
32
+ db.commit()
33
+
34
+ def save_sentiment(text, sentiment):
35
+ """Lưu kết quả phân loại vào database"""
36
+ db = get_db()
37
+ timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
38
+ db.execute(
39
+ 'INSERT INTO sentiments (text, sentiment, timestamp) VALUES (?, ?, ?)',
40
+ (text, sentiment, timestamp)
41
+ )
42
+ db.commit()
43
+
44
+ def get_history(limit=50):
45
+ """Lấy lịch sử phân loại"""
46
+ db = get_db()
47
+ history = db.execute(
48
+ 'SELECT * FROM sentiments ORDER BY timestamp DESC LIMIT ?',
49
+ (limit,)
50
+ ).fetchall()
51
+ return history
app/nlp_processor.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
3
+ os.environ['TRANSFORMERS_NO_TF'] = '1'
4
+
5
+ from transformers import pipeline
6
+ import re
7
+
8
+ # Import Underthesea (tùy chọn theo đề bài)
9
+ try:
10
+ from underthesea import word_tokenize
11
+ USE_UNDERTHESEA = True
12
+ except ImportError:
13
+ USE_UNDERTHESEA = False
14
+ print("⚠️ Underthesea không được cài đặt, sẽ dùng chuẩn hóa cơ bản")
15
+
16
+ class SentimentProcessor:
17
+ def __init__(self):
18
+ """Khởi tạo pipeline phân loại cảm xúc theo đề bài"""
19
+ print("\n🔄 Đang tải model Transformer...")
20
+ try:
21
+ # Sử dụng pipeline sentiment-analysis như đề bài yêu cầu
22
+ # Model: cardiffnlp/twitter-xlm-roberta-base-sentiment (multilingual, đã train sentiment)
23
+ self.classifier = pipeline(
24
+ "sentiment-analysis",
25
+ model="cardiffnlp/twitter-xlm-roberta-base-sentiment", # Hỗ trợ đa ngôn ngữ, đã train sentiment
26
+ device=-1 # CPU
27
+ )
28
+ print("✅ Đã tải model XLM-RoBERTa Multilingual Sentiment")
29
+ except Exception as e:
30
+ print(f"❌ Lỗi khi tải model: {e}")
31
+ raise
32
+
33
+ # Từ điển chuẩn hóa từ viết tắt
34
+ self.normalization_dict = {
35
+ 'rat': 'rất',
36
+ 'k': 'không',
37
+ 'ko': 'không',
38
+ 'hok': 'không',
39
+ 'dc': 'được',
40
+ 'dk': 'được',
41
+ 'ms': 'mới',
42
+ 'mk': 'mình',
43
+ 'mik': 'mình',
44
+ 'cx': 'cũng',
45
+ 'j': 'gì',
46
+ 'vs': 'với',
47
+ 'tks': 'cảm ơn',
48
+ 'thank': 'cảm ơn',
49
+ }
50
+
51
+ def preprocess_text(self, text):
52
+ """Tiền xử lý văn bản theo đề bài"""
53
+ if not text or len(text.strip()) < 5:
54
+ raise ValueError("Câu quá ngắn hoặc rỗng")
55
+
56
+ # Giới hạn độ dài ≤ 50 ký tự theo đề bài (Component 1: Tiền xử lý)
57
+ if len(text) > 50:
58
+ text = text[:50]
59
+
60
+ # Loại bỏ khoảng trắng thừa
61
+ text = ' '.join(text.split())
62
+
63
+ # Chuẩn hóa từ viết tắt (10-20 từ phổ biến theo đề bài)
64
+ words = text.lower().split()
65
+ normalized_words = [self.normalization_dict.get(word, word) for word in words]
66
+ text = ' '.join(normalized_words)
67
+
68
+ # Tùy chọn: Dùng Underthesea để tokenize tiếng Việt
69
+ if USE_UNDERTHESEA:
70
+ try:
71
+ text = word_tokenize(text, format="text")
72
+ except:
73
+ pass # Nếu lỗi thì dùng text gốc
74
+
75
+ return text
76
+
77
+ def classify_sentiment(self, text):
78
+ """Phân loại cảm xúc theo đề bài (Component 2 & 3)"""
79
+ try:
80
+ # Component 1: Tiền xử lý
81
+ processed_text = self.preprocess_text(text)
82
+
83
+ # Component 2: Phân loại cảm xúc qua pipeline
84
+ result = self.classifier(processed_text)[0]
85
+
86
+ # Chuẩn hóa nhãn về 3 loại theo đề bài
87
+ label = result['label'].upper()
88
+ score = result['score']
89
+
90
+ # Mapping các nhãn khác nhau về POSITIVE, NEUTRAL, NEGATIVE
91
+ if 'POS' in label or 'LABEL_2' in label or '5' in label or '4' in label:
92
+ sentiment = 'POSITIVE'
93
+ elif 'NEG' in label or 'LABEL_0' in label or '1' in label or '2' in label:
94
+ sentiment = 'NEGATIVE'
95
+ else:
96
+ sentiment = 'NEUTRAL'
97
+
98
+ # Component 3: Nếu xác suất < 0.5, trả về NEUTRAL mặc định theo đề bài
99
+ if score < 0.5:
100
+ sentiment = 'NEUTRAL'
101
+
102
+ # Đầu ra dictionary theo đề bài (chỉ 2 trường bắt buộc)
103
+ return {
104
+ 'text': text,
105
+ 'sentiment': sentiment,
106
+ 'score': round(score, 2) # Thêm score để hiển thị
107
+ }
108
+
109
+ except ValueError as e:
110
+ raise e
111
+ except Exception as e:
112
+ raise Exception(f"Lỗi khi phân loại: {str(e)}")
113
+
114
+ # Khởi tạo processor toàn cục (cache để tái sử dụng)
115
+ _processor = None
116
+
117
+ def get_processor():
118
+ """Lấy processor (singleton pattern)"""
119
+ global _processor
120
+ if _processor is None:
121
+ _processor = SentimentProcessor()
122
+ return _processor
app/routes.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Blueprint, render_template, request, jsonify, g
2
+ from app.database import init_db, save_sentiment, get_history, close_db
3
+ from app.nlp_processor import get_processor
4
+
5
+ bp = Blueprint('main', __name__)
6
+
7
+ # Đóng database sau mỗi request
8
+ @bp.teardown_app_request
9
+ def teardown_db(error):
10
+ close_db(error)
11
+
12
+ @bp.route('/')
13
+ def index():
14
+ """Trang chủ"""
15
+ # Khởi tạo database nếu chưa có
16
+ init_db()
17
+ return render_template('index.html')
18
+
19
+ @bp.route('/analyze', methods=['POST'])
20
+ def analyze():
21
+ """API phân loại cảm xúc"""
22
+ try:
23
+ data = request.get_json()
24
+ text = data.get('text', '').strip()
25
+
26
+ if not text:
27
+ return jsonify({'error': 'Vui lòng nhập câu cần phân loại'}), 400
28
+
29
+ if len(text) < 5:
30
+ return jsonify({'error': 'Câu quá ngắn, vui lòng nhập ít nhất 5 ký tự'}), 400
31
+
32
+ # Phân loại cảm xúc
33
+ processor = get_processor()
34
+ result = processor.classify_sentiment(text)
35
+
36
+ # Lưu vào database
37
+ save_sentiment(result['text'], result['sentiment'])
38
+
39
+ # Trả về kết quả
40
+ return jsonify({
41
+ 'success': True,
42
+ 'result': result
43
+ })
44
+
45
+ except ValueError as e:
46
+ return jsonify({'error': str(e)}), 400
47
+ except Exception as e:
48
+ return jsonify({'error': f'Đã xảy ra lỗi: {str(e)}'}), 500
49
+
50
+ @bp.route('/history')
51
+ def history():
52
+ """API lấy lịch sử phân loại"""
53
+ try:
54
+ limit = request.args.get('limit', 50, type=int)
55
+ history_data = get_history(limit)
56
+
57
+ # Chuyển đổi sang dictionary
58
+ results = []
59
+ for row in history_data:
60
+ results.append({
61
+ 'id': row['id'],
62
+ 'text': row['text'],
63
+ 'sentiment': row['sentiment'],
64
+ 'timestamp': row['timestamp']
65
+ })
66
+
67
+ return jsonify({
68
+ 'success': True,
69
+ 'history': results
70
+ })
71
+
72
+ except Exception as e:
73
+ return jsonify({'error': f'Đã xảy ra lỗi: {str(e)}'}), 500
app/static/css/style.css ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ * {
2
+ margin: 0;
3
+ padding: 0;
4
+ box-sizing: border-box;
5
+ }
6
+
7
+ body {
8
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
9
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
10
+ min-height: 100vh;
11
+ padding: 20px;
12
+ color: #333;
13
+ }
14
+
15
+ .container {
16
+ max-width: 1200px;
17
+ margin: 0 auto;
18
+ }
19
+
20
+ header {
21
+ text-align: center;
22
+ color: white;
23
+ margin-bottom: 40px;
24
+ }
25
+
26
+ header h1 {
27
+ font-size: 2.5em;
28
+ margin-bottom: 10px;
29
+ text-shadow: 2px 2px 4px rgba(0,0,0,0.2);
30
+ }
31
+
32
+ .subtitle {
33
+ font-size: 1.1em;
34
+ opacity: 0.9;
35
+ }
36
+
37
+ .card {
38
+ background: white;
39
+ border-radius: 12px;
40
+ padding: 30px;
41
+ box-shadow: 0 10px 30px rgba(0,0,0,0.2);
42
+ margin-bottom: 30px;
43
+ }
44
+
45
+ .card h2 {
46
+ color: #667eea;
47
+ margin-bottom: 20px;
48
+ font-size: 1.5em;
49
+ }
50
+
51
+ /* Input Section */
52
+ .input-section textarea {
53
+ width: 100%;
54
+ padding: 15px;
55
+ border: 2px solid #e0e0e0;
56
+ border-radius: 8px;
57
+ font-size: 16px;
58
+ font-family: inherit;
59
+ resize: vertical;
60
+ margin-bottom: 15px;
61
+ transition: border-color 0.3s;
62
+ }
63
+
64
+ .input-section textarea:focus {
65
+ outline: none;
66
+ border-color: #667eea;
67
+ }
68
+
69
+ .btn {
70
+ padding: 12px 30px;
71
+ border: none;
72
+ border-radius: 8px;
73
+ font-size: 16px;
74
+ font-weight: 600;
75
+ cursor: pointer;
76
+ transition: all 0.3s;
77
+ }
78
+
79
+ .btn-primary {
80
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
81
+ color: white;
82
+ width: 100%;
83
+ }
84
+
85
+ .btn-primary:hover {
86
+ transform: translateY(-2px);
87
+ box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
88
+ }
89
+
90
+ .btn-primary:active {
91
+ transform: translateY(0);
92
+ }
93
+
94
+ .btn-secondary {
95
+ background: #f0f0f0;
96
+ color: #666;
97
+ padding: 8px 16px;
98
+ font-size: 14px;
99
+ }
100
+
101
+ .btn-secondary:hover {
102
+ background: #e0e0e0;
103
+ }
104
+
105
+ .loading {
106
+ text-align: center;
107
+ margin-top: 15px;
108
+ color: #667eea;
109
+ font-weight: 600;
110
+ }
111
+
112
+ /* Result Section */
113
+ .result-section {
114
+ animation: fadeIn 0.5s;
115
+ }
116
+
117
+ @keyframes fadeIn {
118
+ from {
119
+ opacity: 0;
120
+ transform: translateY(-10px);
121
+ }
122
+ to {
123
+ opacity: 1;
124
+ transform: translateY(0);
125
+ }
126
+ }
127
+
128
+ .result-content {
129
+ text-align: center;
130
+ }
131
+
132
+ .sentiment-badge {
133
+ display: inline-block;
134
+ padding: 15px 40px;
135
+ border-radius: 50px;
136
+ font-size: 1.3em;
137
+ font-weight: 700;
138
+ margin-bottom: 20px;
139
+ text-transform: uppercase;
140
+ }
141
+
142
+ .sentiment-positive {
143
+ background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
144
+ color: white;
145
+ }
146
+
147
+ .sentiment-negative {
148
+ background: linear-gradient(135deg, #eb3349 0%, #f45c43 100%);
149
+ color: white;
150
+ }
151
+
152
+ .sentiment-neutral {
153
+ background: linear-gradient(135deg, #bdc3c7 0%, #95a5a6 100%);
154
+ color: white;
155
+ }
156
+
157
+ .analyzed-text {
158
+ font-size: 1.1em;
159
+ color: #666;
160
+ margin-bottom: 10px;
161
+ font-style: italic;
162
+ }
163
+
164
+ .confidence-score {
165
+ color: #999;
166
+ font-size: 0.9em;
167
+ }
168
+
169
+ /* History Section */
170
+ .history-header {
171
+ display: flex;
172
+ justify-content: space-between;
173
+ align-items: center;
174
+ margin-bottom: 20px;
175
+ }
176
+
177
+ .history-list {
178
+ max-height: 500px;
179
+ overflow-y: auto;
180
+ }
181
+
182
+ .history-item {
183
+ padding: 15px;
184
+ border-left: 4px solid #667eea;
185
+ background: #f8f9fa;
186
+ margin-bottom: 10px;
187
+ border-radius: 4px;
188
+ transition: all 0.3s;
189
+ }
190
+
191
+ .history-item:hover {
192
+ background: #e9ecef;
193
+ transform: translateX(5px);
194
+ }
195
+
196
+ .history-item-header {
197
+ display: flex;
198
+ justify-content: space-between;
199
+ align-items: center;
200
+ margin-bottom: 8px;
201
+ }
202
+
203
+ .history-sentiment {
204
+ padding: 4px 12px;
205
+ border-radius: 20px;
206
+ font-size: 0.85em;
207
+ font-weight: 600;
208
+ }
209
+
210
+ .history-timestamp {
211
+ color: #999;
212
+ font-size: 0.85em;
213
+ }
214
+
215
+ .history-text {
216
+ color: #555;
217
+ line-height: 1.5;
218
+ }
219
+
220
+ .empty-message {
221
+ text-align: center;
222
+ color: #999;
223
+ padding: 40px;
224
+ }
225
+
226
+ /* Popup */
227
+ .popup {
228
+ position: fixed;
229
+ top: 0;
230
+ left: 0;
231
+ width: 100%;
232
+ height: 100%;
233
+ background: rgba(0,0,0,0.5);
234
+ display: flex;
235
+ justify-content: center;
236
+ align-items: center;
237
+ z-index: 1000;
238
+ }
239
+
240
+ .popup-content {
241
+ background: white;
242
+ padding: 30px;
243
+ border-radius: 12px;
244
+ max-width: 500px;
245
+ position: relative;
246
+ animation: slideDown 0.3s;
247
+ }
248
+
249
+ @keyframes slideDown {
250
+ from {
251
+ transform: translateY(-50px);
252
+ opacity: 0;
253
+ }
254
+ to {
255
+ transform: translateY(0);
256
+ opacity: 1;
257
+ }
258
+ }
259
+
260
+ .popup-close {
261
+ position: absolute;
262
+ top: 10px;
263
+ right: 15px;
264
+ font-size: 28px;
265
+ cursor: pointer;
266
+ color: #999;
267
+ }
268
+
269
+ .popup-close:hover {
270
+ color: #333;
271
+ }
272
+
273
+ #popupMessage {
274
+ font-size: 1.1em;
275
+ color: #666;
276
+ margin-top: 10px;
277
+ }
278
+
279
+ footer {
280
+ text-align: center;
281
+ color: white;
282
+ margin-top: 40px;
283
+ opacity: 0.9;
284
+ }
285
+
286
+ /* Scrollbar styling */
287
+ .history-list::-webkit-scrollbar {
288
+ width: 8px;
289
+ }
290
+
291
+ .history-list::-webkit-scrollbar-track {
292
+ background: #f1f1f1;
293
+ border-radius: 4px;
294
+ }
295
+
296
+ .history-list::-webkit-scrollbar-thumb {
297
+ background: #667eea;
298
+ border-radius: 4px;
299
+ }
300
+
301
+ .history-list::-webkit-scrollbar-thumb:hover {
302
+ background: #5568d3;
303
+ }
304
+
305
+ /* Responsive */
306
+ @media (max-width: 768px) {
307
+ header h1 {
308
+ font-size: 1.8em;
309
+ }
310
+
311
+ .card {
312
+ padding: 20px;
313
+ }
314
+
315
+ .history-header {
316
+ flex-direction: column;
317
+ gap: 10px;
318
+ }
319
+ }
app/static/js/main.js ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Khởi tạo khi trang load
2
+ document.addEventListener('DOMContentLoaded', function() {
3
+ loadHistory();
4
+
5
+ // Event listeners
6
+ document.getElementById('analyzeBtn').addEventListener('click', analyzeSentiment);
7
+ document.getElementById('refreshBtn').addEventListener('click', loadHistory);
8
+ document.querySelector('.popup-close').addEventListener('click', closePopup);
9
+
10
+ // Enter key để phân tích
11
+ document.getElementById('inputText').addEventListener('keypress', function(e) {
12
+ if (e.key === 'Enter' && !e.shiftKey) {
13
+ e.preventDefault();
14
+ analyzeSentiment();
15
+ }
16
+ });
17
+ });
18
+
19
+ // Hàm phân loại cảm xúc
20
+ async function analyzeSentiment() {
21
+ const inputText = document.getElementById('inputText').value.trim();
22
+ const loading = document.getElementById('loading');
23
+ const analyzeBtn = document.getElementById('analyzeBtn');
24
+
25
+ // Validate input
26
+ if (!inputText) {
27
+ showPopup('Vui lòng nhập câu cần phân loại!');
28
+ return;
29
+ }
30
+
31
+ if (inputText.length < 5) {
32
+ showPopup('Câu quá ngắn! Vui lòng nhập ít nhất 5 ký tự.');
33
+ return;
34
+ }
35
+
36
+ // Show loading
37
+ loading.style.display = 'block';
38
+ analyzeBtn.disabled = true;
39
+
40
+ try {
41
+ const response = await fetch('/analyze', {
42
+ method: 'POST',
43
+ headers: {
44
+ 'Content-Type': 'application/json'
45
+ },
46
+ body: JSON.stringify({ text: inputText })
47
+ });
48
+
49
+ const data = await response.json();
50
+
51
+ if (data.success) {
52
+ displayResult(data.result);
53
+ loadHistory(); // Refresh history
54
+ document.getElementById('inputText').value = ''; // Clear input
55
+ } else {
56
+ showPopup(data.error || 'Đã xảy ra lỗi khi phân loại');
57
+ }
58
+
59
+ } catch (error) {
60
+ showPopup('Không thể kết nối đến server. Vui lòng thử lại!');
61
+ console.error('Error:', error);
62
+ } finally {
63
+ loading.style.display = 'none';
64
+ analyzeBtn.disabled = false;
65
+ }
66
+ }
67
+
68
+ // Hiển thị kết quả phân loại
69
+ function displayResult(result) {
70
+ const resultSection = document.getElementById('resultSection');
71
+ const sentimentBadge = document.getElementById('sentimentBadge');
72
+ const analyzedText = document.getElementById('analyzedText');
73
+ const confidenceScore = document.getElementById('confidenceScore');
74
+
75
+ // Map sentiment to Vietnamese
76
+ const sentimentMap = {
77
+ 'POSITIVE': { text: '😊 Tích cực', class: 'sentiment-positive' },
78
+ 'NEGATIVE': { text: '😞 Tiêu cực', class: 'sentiment-negative' },
79
+ 'NEUTRAL': { text: '😐 Trung tính', class: 'sentiment-neutral' }
80
+ };
81
+
82
+ const sentiment = sentimentMap[result.sentiment] || sentimentMap['NEUTRAL'];
83
+
84
+ // Update badge
85
+ sentimentBadge.textContent = sentiment.text;
86
+ sentimentBadge.className = 'sentiment-badge ' + sentiment.class;
87
+
88
+ // Update text
89
+ analyzedText.textContent = `"${result.text}"`;
90
+
91
+ // Update confidence
92
+ if (result.score) {
93
+ confidenceScore.textContent = `Độ tin cậy: ${(result.score * 100).toFixed(0)}%`;
94
+ } else {
95
+ confidenceScore.textContent = '';
96
+ }
97
+
98
+ // Show result section
99
+ resultSection.style.display = 'block';
100
+
101
+ // Scroll to result
102
+ resultSection.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
103
+ }
104
+
105
+ // Load lịch sử phân loại
106
+ async function loadHistory() {
107
+ const historyList = document.getElementById('historyList');
108
+
109
+ try {
110
+ const response = await fetch('/history');
111
+ const data = await response.json();
112
+
113
+ if (data.success && data.history.length > 0) {
114
+ historyList.innerHTML = data.history.map(item => createHistoryItem(item)).join('');
115
+ } else {
116
+ historyList.innerHTML = '<p class="empty-message">Chưa có lịch sử phân loại</p>';
117
+ }
118
+
119
+ } catch (error) {
120
+ console.error('Error loading history:', error);
121
+ historyList.innerHTML = '<p class="empty-message">Không thể tải lịch sử</p>';
122
+ }
123
+ }
124
+
125
+ // Tạo HTML cho item lịch sử
126
+ function createHistoryItem(item) {
127
+ const sentimentMap = {
128
+ 'POSITIVE': { text: 'Tích cực', class: 'sentiment-positive' },
129
+ 'NEGATIVE': { text: 'Tiêu cực', class: 'sentiment-negative' },
130
+ 'NEUTRAL': { text: 'Trung tính', class: 'sentiment-neutral' }
131
+ };
132
+
133
+ const sentiment = sentimentMap[item.sentiment] || sentimentMap['NEUTRAL'];
134
+
135
+ return `
136
+ <div class="history-item">
137
+ <div class="history-item-header">
138
+ <span class="history-sentiment ${sentiment.class}">${sentiment.text}</span>
139
+ <span class="history-timestamp">${item.timestamp}</span>
140
+ </div>
141
+ <div class="history-text">${item.text}</div>
142
+ </div>
143
+ `;
144
+ }
145
+
146
+ // Hiển thị popup thông báo
147
+ function showPopup(message) {
148
+ const popup = document.getElementById('popup');
149
+ const popupMessage = document.getElementById('popupMessage');
150
+
151
+ popupMessage.textContent = message;
152
+ popup.style.display = 'flex';
153
+
154
+ // Auto close after 3 seconds
155
+ setTimeout(closePopup, 3000);
156
+ }
157
+
158
+ // Đóng popup
159
+ function closePopup() {
160
+ document.getElementById('popup').style.display = 'none';
161
+ }
162
+
163
+ // Close popup when clicking outside
164
+ window.addEventListener('click', function(e) {
165
+ const popup = document.getElementById('popup');
166
+ if (e.target === popup) {
167
+ closePopup();
168
+ }
169
+ });
app/templates/index.html ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="vi">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Trợ lý Phân loại Cảm xúc Tiếng Việt</title>
7
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
8
+ </head>
9
+ <body>
10
+ <div class="container">
11
+ <header>
12
+ <h1>🤖 Trợ lý Phân loại Cảm xúc Tiếng Việt</h1>
13
+ <p class="subtitle">Sử dụng Transformer để phân tích cảm xúc từ văn bản tiếng Việt</p>
14
+ </header>
15
+
16
+ <main>
17
+ <!-- Khu vực nhập liệu -->
18
+ <section class="input-section">
19
+ <div class="card">
20
+ <h2>Nhập câu cần phân tích</h2>
21
+ <textarea
22
+ id="inputText"
23
+ placeholder="Ví dụ: Hôm nay tôi rất vui..."
24
+ rows="4"
25
+ ></textarea>
26
+ <button id="analyzeBtn" class="btn btn-primary">
27
+ Phân loại Cảm xúc
28
+ </button>
29
+ <div id="loading" class="loading" style="display: none;">
30
+ <span>Đang xử lý...</span>
31
+ </div>
32
+ </div>
33
+ </section>
34
+
35
+ <!-- Khu vực hiển thị kết quả -->
36
+ <section class="result-section" id="resultSection" style="display: none;">
37
+ <div class="card result-card">
38
+ <h2>Kết quả phân tích</h2>
39
+ <div class="result-content">
40
+ <div class="sentiment-badge" id="sentimentBadge"></div>
41
+ <p class="analyzed-text" id="analyzedText"></p>
42
+ <p class="confidence-score" id="confidenceScore"></p>
43
+ </div>
44
+ </div>
45
+ </section>
46
+
47
+ <!-- Lịch sử phân loại -->
48
+ <section class="history-section">
49
+ <div class="card">
50
+ <div class="history-header">
51
+ <h2>Lịch sử phân loại</h2>
52
+ <button id="refreshBtn" class="btn btn-secondary">
53
+ 🔄 Làm mới
54
+ </button>
55
+ </div>
56
+ <div id="historyList" class="history-list">
57
+ <p class="empty-message">Chưa có lịch sử phân loại</p>
58
+ </div>
59
+ </div>
60
+ </section>
61
+ </main>
62
+
63
+ <footer>
64
+ <p>Đồ án Chuyên đề - Phân loại Cảm xúc Tiếng Việt</p>
65
+ </footer>
66
+ </div>
67
+
68
+ <!-- Popup thông báo -->
69
+ <div id="popup" class="popup" style="display: none;">
70
+ <div class="popup-content">
71
+ <span class="popup-close">&times;</span>
72
+ <p id="popupMessage"></p>
73
+ </div>
74
+ </div>
75
+
76
+ <script src="{{ url_for('static', filename='js/main.js') }}"></script>
77
+ </body>
78
+ </html>
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ Flask==3.0.0
2
+ transformers==4.35.0
3
+ torch==2.1.0
4
+ Werkzeug==3.0.1
5
+ underthesea==6.7.0
run.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
4
+ os.environ['TRANSFORMERS_NO_TF'] = '1'
5
+
6
+ from app import create_app
7
+
8
+ app = create_app()
9
+
10
+ if __name__ == '__main__':
11
+ # Lấy port từ environment variable (cho Hugging Face Spaces) hoặc mặc định 5000
12
+ port = int(os.environ.get('PORT', 5000))
13
+
14
+ print("\n" + "="*50)
15
+ print("🚀 Ứng dụng Phân loại Cảm xúc Tiếng Việt")
16
+ print("="*50)
17
+ print(f"📍 URL: http://localhost:{port}")
18
+ print("⚠️ Lần đầu chạy sẽ mất 10-30 giây để tải model...")
19
+ print("="*50 + "\n")
20
+
21
+ app.run(debug=False, host='0.0.0.0', port=port, use_reloader=False)