Ptul2x5 commited on
Commit
68f763b
·
verified ·
1 Parent(s): 0b80577
Files changed (9) hide show
  1. PhoBERTPairABSA.py +24 -0
  2. app.py +188 -218
  3. database_manager.py +5 -49
  4. forms.py +1 -8
  5. model_config.py +342 -0
  6. models.py +3 -4
  7. requirements.txt +0 -7
  8. static/css/style.css +26 -0
  9. static/js/app.js +105 -8
PhoBERTPairABSA.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from torch import nn
3
+ from transformers import AutoModel
4
+
5
+ class PhoBERTPairABSA(nn.Module):
6
+ """Pair-ABSA model: Predicts sentiment for a specific topic in a sentence"""
7
+ def __init__(self, base_model="vinai/phobert-base", num_cls=4, dropout=0.2):
8
+ super().__init__()
9
+ self.backbone = AutoModel.from_pretrained(base_model)
10
+ hidden_size = self.backbone.config.hidden_size
11
+ self.classifier = nn.Sequential(
12
+ nn.Dropout(dropout),
13
+ nn.Linear(hidden_size, hidden_size),
14
+ nn.GELU(),
15
+ nn.LayerNorm(hidden_size),
16
+ nn.Dropout(dropout),
17
+ nn.Linear(hidden_size, num_cls)
18
+ )
19
+
20
+ def forward(self, input_ids, attention_mask):
21
+ out = self.backbone(input_ids=input_ids, attention_mask=attention_mask)
22
+ cls = out.last_hidden_state[:, 0, :]
23
+ logits = self.classifier(cls)
24
+ return logits
app.py CHANGED
@@ -12,66 +12,60 @@ from flask_login import LoginManager, login_user, logout_user, login_required, c
12
  from functools import wraps
13
  from models import db, User, Feedback
14
  from forms import RegistrationForm, LoginForm
15
- from PhoBERTMultiTask import PhoBERTMultiTask
16
  from datetime import datetime, timedelta
17
  import pytz
18
  from database_manager import db_manager
 
 
 
 
 
 
19
 
20
  app = Flask(__name__)
21
- # Cấu hình
22
  app.config['SECRET_KEY'] = 'your-secret-key-change-this-in-production'
23
 
24
- # Database backup functions
25
  def backup_database(force: bool = False):
26
  """Backup database to Hugging Face Hub"""
27
  try:
28
  return db_manager.backup_database(force=force)
29
- except Exception as e:
30
- print(f"❌ Backup error: {e}")
31
  return False
32
 
33
  def restore_database():
34
  """Restore database from Hugging Face Hub"""
35
  try:
36
  return db_manager.restore_database()
37
- except Exception as e:
38
- print(f"❌ Restore error: {e}")
39
  return False
40
 
41
  def run_scheduler():
42
  """Run scheduled backup every hour"""
43
  while True:
44
  schedule.run_pending()
45
- time.sleep(60) # Check every minute
46
 
47
- # Schedule backup every hour
48
  schedule.every().hour.do(backup_database)
49
-
50
- # Start scheduler in background thread
51
  scheduler_thread = Thread(target=run_scheduler, daemon=True)
52
  scheduler_thread.start()
53
-
54
- # Backup on shutdown
55
  atexit.register(backup_database)
56
 
57
- # Thiết lập múi giờ Việt Nam
58
  VIETNAM_TIMEZONE = pytz.timezone('Asia/Ho_Chi_Minh')
59
 
60
  def utc_to_vietnam_time(utc_datetime):
61
  """Chuyển đổi thời gian UTC sang múi giờ Việt Nam"""
62
  if utc_datetime is None:
63
  return None
64
- # Nếu datetime không có timezone info, coi như UTC
65
  if utc_datetime.tzinfo is None:
66
  utc_datetime = pytz.utc.localize(utc_datetime)
67
  return utc_datetime.astimezone(VIETNAM_TIMEZONE)
68
- # Sử dụng đường dẫn database phù hợp với Hugging Face Spaces
69
  db_path = os.path.join(os.getcwd(), 'instance', 'feedback_analysis.db')
70
  os.makedirs(os.path.dirname(db_path), exist_ok=True)
71
  app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}'
72
  app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
73
 
74
- # Khởi tạo extensions
75
  db.init_app(app)
76
  login_manager = LoginManager()
77
  login_manager.init_app(app)
@@ -79,7 +73,6 @@ login_manager.login_view = 'login'
79
  login_manager.login_message = 'Vui lòng đăng nhập để sử dụng hệ thống phân tích feedback.'
80
  login_manager.login_message_category = 'info'
81
 
82
- # Thêm hàm chuyển đổi múi giờ vào template context
83
  @app.context_processor
84
  def utility_processor():
85
  return dict(utc_to_vietnam_time=utc_to_vietnam_time)
@@ -88,8 +81,131 @@ def utility_processor():
88
  def load_user(user_id):
89
  return User.query.get(int(user_id))
90
 
91
- # Decorator để yêu cầu quyền admin
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  def admin_required(f):
 
93
  @wraps(f)
94
  def decorated_function(*args, **kwargs):
95
  if not current_user.is_authenticated:
@@ -101,30 +217,27 @@ def admin_required(f):
101
  return decorated_function
102
 
103
  device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
104
-
105
- # === Load tokenizer & model ===
106
- MODEL_REPO = "Ptul2x5/Student_Feedback_Sentiment" # 🔹 Repo Hugging Face của bạn
107
 
108
  try:
109
- # Disable hf_transfer to avoid the error
110
  os.environ['HF_HUB_ENABLE_HF_TRANSFER'] = '0'
111
-
112
  tokenizer = AutoTokenizer.from_pretrained(MODEL_REPO, use_fast=False)
113
-
114
- # Load model weights from Hugging Face
115
- MODEL_URL = f"https://huggingface.co/{MODEL_REPO}/resolve/main/multitask_model.bin"
116
- state_dict = torch.hub.load_state_dict_from_url(MODEL_URL, map_location=device)
117
-
118
- model = PhoBERTMultiTask(num_sentiment=3, num_topic=4)
 
 
 
119
  model.load_state_dict(state_dict, strict=False)
120
  model.to(device)
121
  model.eval()
122
- except Exception as e:
123
- print(f"Error loading model: {e}")
124
  model = None
125
  tokenizer = None
126
 
127
- # ====== ROUTES ======
128
  @app.route("/", methods=["GET"])
129
  @login_required
130
  def home():
@@ -141,10 +254,7 @@ def register():
141
  user.set_password(form.password.data)
142
  db.session.add(user)
143
  db.session.commit()
144
-
145
- # Backup database after user registration
146
  backup_database()
147
-
148
  flash('Đăng ký thành công! Vui lòng đăng nhập.', 'success')
149
  return redirect(url_for('login'))
150
 
@@ -177,34 +287,27 @@ def logout():
177
 
178
  @app.route("/api/health", methods=["GET"])
179
  def health():
180
- return jsonify({"status": "healthy", "message": "✅ PhoBERT MultiTask API is running!"})
181
 
182
  @app.route("/my-statistics")
183
  @login_required
184
  def my_statistics():
185
- """Trang thống kê feedback cá nhân của user"""
186
-
187
  try:
188
- # Lấy thống kê feedback của user hiện tại
189
  user_feedbacks = Feedback.query.filter_by(user_id=current_user.id).all()
190
  total_feedbacks = len(user_feedbacks)
191
 
192
- # Thống kê sentiment
193
  sentiment_stats = db.session.query(
194
  Feedback.sentiment,
195
  db.func.count(Feedback.id).label('count')
196
  ).filter_by(user_id=current_user.id).group_by(Feedback.sentiment).all()
197
  sentiment_stats = [{'sentiment': item.sentiment, 'count': item.count} for item in sentiment_stats]
198
 
199
- # Thống kê topic
200
  topic_stats = db.session.query(
201
  Feedback.topic,
202
  db.func.count(Feedback.id).label('count')
203
  ).filter_by(user_id=current_user.id).group_by(Feedback.topic).all()
204
  topic_stats = [{'topic': item.topic, 'count': item.count} for item in topic_stats]
205
 
206
- # Thống kê theo ngày (30 ngày gần nhất)
207
- from datetime import datetime, timedelta
208
  thirty_days_ago = datetime.now() - timedelta(days=30)
209
  daily_stats = db.session.query(
210
  db.func.date(Feedback.created_at).label('date'),
@@ -217,7 +320,6 @@ def my_statistics():
217
  ).order_by('date').all()
218
  daily_stats = [{'date': str(item.date), 'count': item.count} for item in daily_stats]
219
 
220
- # Feedback gần nhất của user
221
  recent_feedbacks = Feedback.query.filter_by(user_id=current_user.id)\
222
  .order_by(Feedback.created_at.desc()).limit(10).all()
223
 
@@ -234,31 +336,23 @@ def my_statistics():
234
  @app.route("/admin/database")
235
  @admin_required
236
  def view_database():
237
- """Trang xem database với giao diện đẹp"""
238
  try:
239
- # Lấy thống kê tổng quan
240
  total_users = User.query.count()
241
  total_feedbacks = Feedback.query.count()
242
-
243
- # Lấy feedbacks gần nhất
244
  recent_feedbacks = Feedback.query.order_by(Feedback.created_at.desc()).limit(10).all()
245
 
246
- # Thống kê sentiment
247
  sentiment_stats = db.session.query(
248
  Feedback.sentiment,
249
  db.func.count(Feedback.id).label('count')
250
  ).group_by(Feedback.sentiment).all()
251
  sentiment_stats = [{'sentiment': item.sentiment, 'count': item.count} for item in sentiment_stats]
252
 
253
- # Thống kê topic
254
  topic_stats = db.session.query(
255
  Feedback.topic,
256
  db.func.count(Feedback.id).label('count')
257
  ).group_by(Feedback.topic).all()
258
  topic_stats = [{'topic': item.topic, 'count': item.count} for item in topic_stats]
259
 
260
- # Thống kê theo ngày (7 ngày gần nhất)
261
- from datetime import datetime, timedelta
262
  seven_days_ago = datetime.now() - timedelta(days=7)
263
  daily_stats = db.session.query(
264
  db.func.date(Feedback.created_at).label('date'),
@@ -282,7 +376,6 @@ def view_database():
282
  @app.route("/api/feedback-history", methods=["GET"])
283
  @login_required
284
  def get_feedback_history():
285
- """Lấy lịch sử feedback của user hiện tại với filter thời gian"""
286
  try:
287
  page = request.args.get('page', 1, type=int)
288
  per_page = request.args.get('per_page', 10, type=int)
@@ -290,53 +383,35 @@ def get_feedback_history():
290
  start_date = request.args.get('start_date', None, type=str)
291
  end_date = request.args.get('end_date', None, type=str)
292
 
293
- # Tạo query base
294
  query = Feedback.query.filter_by(user_id=current_user.id)
295
 
296
- # Áp dụng filter thời gian
297
  if time_filter != 'all':
298
  vietnam_now = utc_to_vietnam_time(datetime.utcnow())
299
 
300
  if time_filter == 'today':
301
- # Từ đầu ngày hôm nay đến hiện tại
302
  today_start = vietnam_now.replace(hour=0, minute=0, second=0, microsecond=0)
303
  today_start_utc = today_start.astimezone(pytz.utc).replace(tzinfo=None)
304
  query = query.filter(Feedback.created_at >= today_start_utc)
305
-
306
  elif time_filter == 'week':
307
- # 1 tuần trước đến hiện tại
308
  week_ago = vietnam_now - timedelta(days=7)
309
  week_ago_utc = week_ago.astimezone(pytz.utc).replace(tzinfo=None)
310
  query = query.filter(Feedback.created_at >= week_ago_utc)
311
-
312
  elif time_filter == 'month':
313
- # 1 tháng trước đến hiện tại
314
  month_ago = vietnam_now - timedelta(days=30)
315
  month_ago_utc = month_ago.astimezone(pytz.utc).replace(tzinfo=None)
316
  query = query.filter(Feedback.created_at >= month_ago_utc)
317
-
318
  elif time_filter == 'custom' and start_date and end_date:
319
- # Filter theo ngày tùy chỉnh
320
  try:
321
  start_datetime = datetime.strptime(start_date, '%Y-%m-%d')
322
  end_datetime = datetime.strptime(end_date, '%Y-%m-%d')
323
-
324
- # Chuyển đổi sang UTC
325
  start_datetime_utc = VIETNAM_TIMEZONE.localize(start_datetime).astimezone(pytz.utc).replace(tzinfo=None)
326
  end_datetime_utc = VIETNAM_TIMEZONE.localize(end_datetime.replace(hour=23, minute=59, second=59)).astimezone(pytz.utc).replace(tzinfo=None)
327
-
328
- query = query.filter(Feedback.created_at >= start_datetime_utc,
329
- Feedback.created_at <= end_datetime_utc)
330
  except ValueError:
331
  return jsonify({'error': 'Định dạng ngày không hợp lệ'}), 400
332
 
333
- # Đếm tổng số feedback theo filter (không phân trang)
334
  total_count = query.count()
335
-
336
-
337
- # Sắp xếp theo thời gian mới nhất và phân trang
338
- feedbacks = query.order_by(Feedback.created_at.desc())\
339
- .paginate(page=page, per_page=per_page, error_out=False)
340
 
341
  feedback_list = []
342
  for feedback in feedbacks.items:
@@ -352,7 +427,7 @@ def get_feedback_history():
352
 
353
  return jsonify({
354
  'feedbacks': feedback_list,
355
- 'total': total_count, # Sử dụng total_count thay vì feedbacks.total
356
  'pages': feedbacks.pages,
357
  'current_page': page,
358
  'has_next': feedbacks.has_next,
@@ -371,63 +446,24 @@ def predict():
371
  if not text:
372
  return jsonify({"error": "Missing 'text' field"}), 400
373
 
374
- # Validate input length
375
  if len(text) > 1000:
376
  return jsonify({"error": "Text quá dài. Vui lòng nhập tối đa 1000 ký tự."}), 400
377
 
378
- # Tokenize
379
- if tokenizer is None:
380
- return jsonify({"error": "Tokenizer not loaded. Please restart the application."}), 500
381
-
382
- inputs = tokenizer(
383
- text, return_tensors="pt", truncation=True, padding=True, max_length=128
384
- ).to(device)
385
 
386
- # Inference
387
- if model is None:
388
- return jsonify({"error": "Model not loaded. Please restart the application."}), 500
389
-
390
- with torch.no_grad():
391
- logits_sent, logits_topic = model(inputs["input_ids"], inputs["attention_mask"])
392
- sent = torch.argmax(logits_sent, dim=1).item()
393
- topic = torch.argmax(logits_topic, dim=1).item()
394
-
395
- # Mapping
396
- SENTIMENT_MAP = {0: "negative", 1: "neutral", 2: "positive"}
397
- TOPIC_MAP = {0: "lecturer", 1: "training_program", 2: "facility", 3: "others"}
398
-
399
- sentiment = SENTIMENT_MAP[sent]
400
- topic_result = TOPIC_MAP[topic]
401
- sentiment_confidence = float(torch.softmax(logits_sent, dim=1).max().item())
402
- topic_confidence = float(torch.softmax(logits_topic, dim=1).max().item())
403
 
404
- # Lưu feedback vào database
405
  try:
406
- feedback = Feedback(
407
- text=text,
408
- sentiment=sentiment,
409
- topic=topic_result,
410
- sentiment_confidence=sentiment_confidence,
411
- topic_confidence=topic_confidence,
412
- user_id=current_user.id
413
- )
414
- db.session.add(feedback)
415
  db.session.commit()
416
-
417
- # Backup database after adding feedback
418
  backup_database()
419
-
420
- except Exception as db_error:
421
- print(f"Database error: {db_error}")
422
- # Không dừng quá trình nếu lưu DB thất bại
423
 
424
  return jsonify({
425
- "sentiment": sentiment,
426
- "topic": topic_result,
427
- "confidence": {
428
- "sentiment": sentiment_confidence,
429
- "topic": topic_confidence
430
- }
431
  })
432
  except Exception as e:
433
  return jsonify({"error": f"Có lỗi xảy ra khi xử lý: {str(e)}"}), 500
@@ -435,7 +471,6 @@ def predict():
435
  @app.route('/admin/backup', methods=['POST'])
436
  @admin_required
437
  def manual_backup():
438
- """Manual backup endpoint for admin"""
439
  try:
440
  if backup_database():
441
  return jsonify({"success": True, "message": "Backup completed successfully"})
@@ -447,7 +482,6 @@ def manual_backup():
447
  @app.route('/admin/restore', methods=['POST'])
448
  @admin_required
449
  def manual_restore():
450
- """Manual restore endpoint for admin"""
451
  try:
452
  if restore_database():
453
  return jsonify({"success": True, "message": "Database restored successfully"})
@@ -456,17 +490,11 @@ def manual_restore():
456
  except Exception as e:
457
  return jsonify({"success": False, "message": f"Restore error: {str(e)}"}), 500
458
 
459
- # Khởi tạo database
460
  with app.app_context():
461
- # Initialize database from backup if needed
462
  db_manager.initialize_database_if_needed()
463
-
464
  db.create_all()
465
-
466
- # Create initial backup after database setup
467
  db_manager.backup_database()
468
 
469
- # Add is_admin column if not exists
470
  try:
471
  db.session.execute(db.text("SELECT is_admin FROM users LIMIT 1"))
472
  except Exception:
@@ -476,11 +504,9 @@ with app.app_context():
476
  except Exception:
477
  pass
478
 
479
- # Create default admin user if database is empty
480
  try:
481
  total_users = User.query.count()
482
  admin_user = User.query.filter_by(username='admin').first()
483
-
484
  if not admin_user and total_users == 0:
485
  admin_user = User(username='admin', is_admin=True)
486
  admin_user.set_password('123456')
@@ -495,7 +521,6 @@ with app.app_context():
495
  @app.route("/analyze-csv", methods=["POST"])
496
  @login_required
497
  def analyze_csv():
498
- """Phân tích nhiều feedback từ file CSV"""
499
  try:
500
  if 'csvFile' not in request.files:
501
  return jsonify({'error': 'Không tìm thấy file CSV'}), 400
@@ -507,22 +532,18 @@ def analyze_csv():
507
  if not file.filename.lower().endswith('.csv'):
508
  return jsonify({'error': 'File phải có định dạng CSV'}), 400
509
 
510
- # Đọc và validate file CSV
511
  try:
512
- # Thử decode file với UTF-8
513
  file_content = file.stream.read().decode("UTF8")
514
  except UnicodeDecodeError:
515
- return jsonify({'error': 'File CSV phải được mã hóa UTF-8. Vui lòng lưu file với encoding UTF-8 và thử lại.'}), 400
516
 
517
  try:
518
  stream = io.StringIO(file_content, newline=None)
519
  csv_input = csv.DictReader(stream)
520
 
521
- # Kiểm tra file có header không
522
  if not csv_input.fieldnames:
523
- return jsonify({'error': 'File CSV không có header (tên cột). Vui lòng thêm header vào file CSV.'}), 400
524
 
525
- # Tìm cột chứa feedback
526
  feedback_column = None
527
  available_columns = []
528
  for col in csv_input.fieldnames:
@@ -533,25 +554,22 @@ def analyze_csv():
533
 
534
  if not feedback_column:
535
  return jsonify({
536
- 'error': f'Không tìm thấy cột chứa feedback. Các cột có sẵn: {", ".join(available_columns)}. Tên cột phải là: feedback, text, content hoặc comment'
537
  }), 400
538
 
539
- # Kiểm tra file có dữ liệu không
540
  rows = list(csv_input)
541
  if not rows:
542
- return jsonify({'error': 'File CSV không có dữ liệu (chỉ có header). Vui lòng thêm dữ liệu vào file.'}), 400
543
-
544
  except csv.Error as e:
545
- return jsonify({'error': f'File CSV không đúng định dạng: {str(e)}. Vui lòng kiểm tra lại file CSV.'}), 400
546
  except Exception as e:
547
  return jsonify({'error': f'Lỗi khi đọc file CSV: {str(e)}'}), 400
548
 
549
- feedbacks = []
550
  results = []
551
  processed_count = 0
552
  error_count = 0
553
 
554
- for row_num, row in enumerate(rows, start=1): # Bắt đầu từ 1 vì hiển thị số dòng thực tế (trừ header)
555
  feedback_text = row[feedback_column].strip()
556
 
557
  if not feedback_text:
@@ -564,86 +582,47 @@ def analyze_csv():
564
  continue
565
 
566
  try:
567
- # Phân tích feedback
568
- if tokenizer is None:
569
  results.append({
570
  "row": row_num,
571
  "feedback": feedback_text,
572
- "sentiment": "N/A",
573
- "topic": "N/A",
574
- "sentiment_confidence": 0.0,
575
- "topic_confidence": 0.0,
576
- "error": "Tokenizer not loaded"
577
  })
578
  continue
579
-
580
- inputs = tokenizer(feedback_text, return_tensors="pt", padding=True, truncation=True, max_length=512)
581
- inputs = {k: v.to(device) for k, v in inputs.items()}
582
 
583
- with torch.no_grad():
584
- if model is None:
585
- results.append({
586
- "row": row_num,
587
- "feedback": feedback_text,
588
- "sentiment": "N/A",
589
- "topic": "N/A",
590
- "sentiment_confidence": 0.0,
591
- "topic_confidence": 0.0,
592
- "error": "Model not loaded"
593
- })
594
- continue
595
-
596
- # Gọi model với đúng parameters
597
- sentiment_logits, topic_logits = model(inputs["input_ids"], inputs["attention_mask"])
598
-
599
- sentiment_probs = torch.softmax(sentiment_logits, dim=-1)
600
- topic_probs = torch.softmax(topic_logits, dim=-1)
601
-
602
- sentiment_pred = torch.argmax(sentiment_probs, dim=-1).item()
603
- topic_pred = torch.argmax(topic_probs, dim=-1).item()
604
-
605
- sentiment_confidence = sentiment_probs[0][sentiment_pred].item()
606
- topic_confidence = topic_probs[0][topic_pred].item()
607
-
608
- # Map predictions
609
- sentiment_labels = ['negative', 'neutral', 'positive']
610
- topic_labels = ['lecturer', 'training_program', 'facility', 'others']
611
 
612
- sentiment = sentiment_labels[sentiment_pred]
613
- topic = topic_labels[topic_pred]
614
-
615
- # Lưu vào database
616
  try:
617
- feedback = Feedback(
618
- text=feedback_text,
619
- sentiment=sentiment,
620
- topic=topic,
621
- sentiment_confidence=sentiment_confidence,
622
- topic_confidence=topic_confidence,
623
- user_id=current_user.id
624
- )
625
- db.session.add(feedback)
626
- feedbacks.append(feedback)
 
 
 
627
 
628
  results.append({
629
  'row': row_num,
630
  'text': feedback_text[:100] + '...' if len(feedback_text) > 100 else feedback_text,
631
- 'sentiment': sentiment,
632
- 'topic': topic,
633
- 'sentiment_confidence': round(sentiment_confidence * 100, 1),
634
- 'topic_confidence': round(topic_confidence * 100, 1),
635
  'success': True
636
  })
637
-
638
  processed_count += 1
639
-
640
- except Exception as db_error:
641
- print(f"Database error for row {row_num}: {db_error}")
642
  error_count += 1
643
  results.append({
644
  'row': row_num,
645
  'text': feedback_text[:100] + '...' if len(feedback_text) > 100 else feedback_text,
646
- 'error': f'Lỗi lưu database: {str(db_error)}'
647
  })
648
 
649
  except Exception as e:
@@ -654,27 +633,19 @@ def analyze_csv():
654
  'error': f'Lỗi phân tích: {str(e)}'
655
  })
656
 
657
- # Commit tất cả feedback vào database
658
  try:
659
  db.session.commit()
660
- print(f"✅ Đã lưu {processed_count} feedback vào database")
661
-
662
- # Backup database after CSV processing
663
  backup_database()
664
-
665
  except Exception as commit_error:
666
  db.session.rollback()
667
- print(f"❌ Lỗi khi commit database: {commit_error}")
668
- return jsonify({
669
- 'error': f'Lỗi khi lưu dữ liệu: {str(commit_error)}'
670
- }), 500
671
 
672
  return jsonify({
673
  'success': True,
674
  'total_rows': len(results),
675
  'processed_count': processed_count,
676
  'error_count': error_count,
677
- 'results': results[:50], # Chỉ trả về 50 kết quả đầu tiên để tránh response quá lớn
678
  'message': f'Đã xử lý {processed_count}/{len(results)} feedback thành công'
679
  })
680
 
@@ -685,6 +656,5 @@ def analyze_csv():
685
  }), 500
686
 
687
  if __name__ == "__main__":
688
- # Hugging Face Spaces configuration
689
  debug = os.environ.get("DEBUG", "False").lower() == "true"
690
  app.run(host="0.0.0.0", port=7860, debug=debug)
 
12
  from functools import wraps
13
  from models import db, User, Feedback
14
  from forms import RegistrationForm, LoginForm
15
+ from PhoBERTPairABSA import PhoBERTPairABSA
16
  from datetime import datetime, timedelta
17
  import pytz
18
  from database_manager import db_manager
19
+ from model_config import (
20
+ get_prompt, ASPECTS_EN, ASPECTS_VI, LABEL_MAP, MAX_LEN, PRED_THRESHOLD,
21
+ MIN_SENT_PROB, MIN_MARGIN,
22
+ _is_garbage, _aspect_has_kw, _has_any_kw, _norm_match, ASPECT_REVERSE_MAPPING,
23
+ BASE_MODEL, NUM_CLASSES, DROPOUT
24
+ )
25
 
26
  app = Flask(__name__)
 
27
  app.config['SECRET_KEY'] = 'your-secret-key-change-this-in-production'
28
 
 
29
  def backup_database(force: bool = False):
30
  """Backup database to Hugging Face Hub"""
31
  try:
32
  return db_manager.backup_database(force=force)
33
+ except Exception:
 
34
  return False
35
 
36
  def restore_database():
37
  """Restore database from Hugging Face Hub"""
38
  try:
39
  return db_manager.restore_database()
40
+ except Exception:
 
41
  return False
42
 
43
  def run_scheduler():
44
  """Run scheduled backup every hour"""
45
  while True:
46
  schedule.run_pending()
47
+ time.sleep(60)
48
 
 
49
  schedule.every().hour.do(backup_database)
 
 
50
  scheduler_thread = Thread(target=run_scheduler, daemon=True)
51
  scheduler_thread.start()
 
 
52
  atexit.register(backup_database)
53
 
 
54
  VIETNAM_TIMEZONE = pytz.timezone('Asia/Ho_Chi_Minh')
55
 
56
  def utc_to_vietnam_time(utc_datetime):
57
  """Chuyển đổi thời gian UTC sang múi giờ Việt Nam"""
58
  if utc_datetime is None:
59
  return None
 
60
  if utc_datetime.tzinfo is None:
61
  utc_datetime = pytz.utc.localize(utc_datetime)
62
  return utc_datetime.astimezone(VIETNAM_TIMEZONE)
63
+
64
  db_path = os.path.join(os.getcwd(), 'instance', 'feedback_analysis.db')
65
  os.makedirs(os.path.dirname(db_path), exist_ok=True)
66
  app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{db_path}'
67
  app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
68
 
 
69
  db.init_app(app)
70
  login_manager = LoginManager()
71
  login_manager.init_app(app)
 
73
  login_manager.login_message = 'Vui lòng đăng nhập để sử dụng hệ thống phân tích feedback.'
74
  login_manager.login_message_category = 'info'
75
 
 
76
  @app.context_processor
77
  def utility_processor():
78
  return dict(utc_to_vietnam_time=utc_to_vietnam_time)
 
81
  def load_user(user_id):
82
  return User.query.get(int(user_id))
83
 
84
+ def analyze_feedback(text):
85
+ """Phân tích feedback với model Pair-ABSA"""
86
+ if tokenizer is None or model is None:
87
+ return []
88
+
89
+ text = str(text).strip()
90
+ if _is_garbage(text):
91
+ return []
92
+
93
+ s_norm = _norm_match(text)
94
+
95
+ tau_len = float(PRED_THRESHOLD)
96
+
97
+ logits_list = []
98
+ has_keywords = []
99
+
100
+ with torch.no_grad():
101
+ for aspect_en in ASPECTS_EN:
102
+ aspect_vi = ASPECT_REVERSE_MAPPING.get(aspect_en, "khac")
103
+ prompt = get_prompt(aspect_en, sentence=text, use_subprompt=True)
104
+
105
+ inputs = tokenizer(
106
+ prompt, text,
107
+ return_tensors="pt",
108
+ truncation="only_second", # Chỉ cắt text (second sequence), giữ nguyên prompt
109
+ padding=True,
110
+ max_length=MAX_LEN
111
+ ).to(device)
112
+
113
+ logits = model(inputs["input_ids"], inputs["attention_mask"]).squeeze(0)
114
+ logits_list.append(logits)
115
+ has_keywords.append(_aspect_has_kw(aspect_vi, s_norm))
116
+
117
+ logits_tensor = torch.stack(logits_list, dim=0)
118
+
119
+ probs = torch.softmax(logits_tensor, dim=-1)
120
+ p_none = probs[:, 0]
121
+ conf_not_none = 1.0 - p_none
122
+
123
+ # Giảm cường độ boost để tránh false positive từ keywords
124
+ KW_BOOST = 0.02 # Giảm từ 0.05 xuống 0.02 (từ 5% xuống 2%)
125
+ conf_not_none_boosted = conf_not_none.clone()
126
+ for i, has_kw in enumerate(has_keywords):
127
+ if has_kw:
128
+ conf_not_none_boosted[i] = min(1.0, conf_not_none_boosted[i] + KW_BOOST)
129
+
130
+ # Bước 1: Lọc aspects có confidence >= threshold VÀ có keywords
131
+ # Nếu không có keywords, cần confidence cao hơn nhiều (>= 0.85)
132
+ keep_indices = []
133
+ for i in range(len(ASPECTS_EN)):
134
+ if has_keywords[i]:
135
+ # Có keywords: cần confidence >= threshold
136
+ if conf_not_none_boosted[i] >= tau_len:
137
+ keep_indices.append(i)
138
+ else:
139
+ # Không có keywords: cần confidence rất cao (>= 0.85)
140
+ if conf_not_none_boosted[i] >= 0.85:
141
+ keep_indices.append(i)
142
+
143
+ # Bước 2: Kiểm tra xem có aspect nào có confidence rất cao không (>95%)
144
+ high_confidence_indices = [i for i in keep_indices if conf_not_none_boosted[i] >= 0.95]
145
+
146
+ # Bước 3: Nếu có aspect với confidence rất cao, loại bỏ các aspects khác không có keywords
147
+ if len(high_confidence_indices) > 0:
148
+ # Loại bỏ các aspects không có keywords nếu đã có aspect khác có confidence rất cao
149
+ keep_indices = [i for i in keep_indices if has_keywords[i] or i in high_confidence_indices]
150
+
151
+ # Nếu vẫn còn slot, có thể thêm aspects khác nếu có keywords VÀ confidence đủ cao
152
+ if len(keep_indices) < len(ASPECTS_EN):
153
+ tau_len_adjusted = tau_len - 0.05 # Chỉ giảm 5%
154
+ for i in range(len(ASPECTS_EN)):
155
+ if i not in keep_indices:
156
+ # Chỉ giữ nếu có keywords VÀ confidence >= adjusted + 0.10
157
+ if has_keywords[i] and conf_not_none_boosted[i] >= tau_len_adjusted + 0.10:
158
+ keep_indices.append(i)
159
+
160
+ if not keep_indices:
161
+ return []
162
+
163
+ results = []
164
+ for i in sorted(keep_indices, key=lambda j: float(conf_not_none_boosted[j]), reverse=True):
165
+ sent_probs = probs[i, 1:].clone()
166
+ top_idx = int(torch.argmax(sent_probs).item())
167
+ top_p = float(sent_probs[top_idx].item())
168
+
169
+ sent_probs[top_idx] = -1.0
170
+ second_p = float(sent_probs.max().item())
171
+ margin = top_p - second_p
172
+
173
+ min_margin_adj = MIN_MARGIN
174
+ if has_keywords[i]:
175
+ min_margin_adj = MIN_MARGIN - 0.02
176
+
177
+ if top_p < MIN_SENT_PROB or margin < min_margin_adj:
178
+ continue
179
+
180
+ sentiment_str = LABEL_MAP[top_idx + 1]
181
+ results.append({
182
+ "topic": ASPECTS_EN[i],
183
+ "sentiment": sentiment_str,
184
+ "confidence": float(conf_not_none_boosted[i].item()),
185
+ "sentiment_confidence": top_p,
186
+ "margin": margin
187
+ })
188
+
189
+ results.sort(key=lambda x: x["confidence"], reverse=True)
190
+ return results
191
+
192
+ def save_feedback_to_db(text, results, user_id):
193
+ """Lưu feedback results vào database"""
194
+ for result in results:
195
+ sentiment_conf = result.get('sentiment_confidence', result['confidence'])
196
+ topic_conf = result['confidence']
197
+ feedback = Feedback(
198
+ text=text,
199
+ sentiment=result['sentiment'],
200
+ topic=result['topic'],
201
+ sentiment_confidence=sentiment_conf,
202
+ topic_confidence=topic_conf,
203
+ user_id=user_id
204
+ )
205
+ db.session.add(feedback)
206
+
207
  def admin_required(f):
208
+ """Decorator để yêu cầu quyền admin"""
209
  @wraps(f)
210
  def decorated_function(*args, **kwargs):
211
  if not current_user.is_authenticated:
 
217
  return decorated_function
218
 
219
  device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
220
+ MODEL_REPO = "Ptul2x5/Student_Feedback_Sentiment"
 
 
221
 
222
  try:
 
223
  os.environ['HF_HUB_ENABLE_HF_TRANSFER'] = '0'
 
224
  tokenizer = AutoTokenizer.from_pretrained(MODEL_REPO, use_fast=False)
225
+ MODEL_URL = f"https://huggingface.co/{MODEL_REPO}/resolve/main/model.bin"
226
+ loaded = torch.hub.load_state_dict_from_url(MODEL_URL, map_location=device)
227
+
228
+ if isinstance(loaded, dict) and "model_state" in loaded:
229
+ state_dict = loaded["model_state"]
230
+ else:
231
+ state_dict = loaded
232
+
233
+ model = PhoBERTPairABSA(base_model=BASE_MODEL, num_cls=NUM_CLASSES, dropout=DROPOUT)
234
  model.load_state_dict(state_dict, strict=False)
235
  model.to(device)
236
  model.eval()
237
+ except Exception:
 
238
  model = None
239
  tokenizer = None
240
 
 
241
  @app.route("/", methods=["GET"])
242
  @login_required
243
  def home():
 
254
  user.set_password(form.password.data)
255
  db.session.add(user)
256
  db.session.commit()
 
 
257
  backup_database()
 
258
  flash('Đăng ký thành công! Vui lòng đăng nhập.', 'success')
259
  return redirect(url_for('login'))
260
 
 
287
 
288
  @app.route("/api/health", methods=["GET"])
289
  def health():
290
+ return jsonify({"status": "healthy"})
291
 
292
  @app.route("/my-statistics")
293
  @login_required
294
  def my_statistics():
 
 
295
  try:
 
296
  user_feedbacks = Feedback.query.filter_by(user_id=current_user.id).all()
297
  total_feedbacks = len(user_feedbacks)
298
 
 
299
  sentiment_stats = db.session.query(
300
  Feedback.sentiment,
301
  db.func.count(Feedback.id).label('count')
302
  ).filter_by(user_id=current_user.id).group_by(Feedback.sentiment).all()
303
  sentiment_stats = [{'sentiment': item.sentiment, 'count': item.count} for item in sentiment_stats]
304
 
 
305
  topic_stats = db.session.query(
306
  Feedback.topic,
307
  db.func.count(Feedback.id).label('count')
308
  ).filter_by(user_id=current_user.id).group_by(Feedback.topic).all()
309
  topic_stats = [{'topic': item.topic, 'count': item.count} for item in topic_stats]
310
 
 
 
311
  thirty_days_ago = datetime.now() - timedelta(days=30)
312
  daily_stats = db.session.query(
313
  db.func.date(Feedback.created_at).label('date'),
 
320
  ).order_by('date').all()
321
  daily_stats = [{'date': str(item.date), 'count': item.count} for item in daily_stats]
322
 
 
323
  recent_feedbacks = Feedback.query.filter_by(user_id=current_user.id)\
324
  .order_by(Feedback.created_at.desc()).limit(10).all()
325
 
 
336
  @app.route("/admin/database")
337
  @admin_required
338
  def view_database():
 
339
  try:
 
340
  total_users = User.query.count()
341
  total_feedbacks = Feedback.query.count()
 
 
342
  recent_feedbacks = Feedback.query.order_by(Feedback.created_at.desc()).limit(10).all()
343
 
 
344
  sentiment_stats = db.session.query(
345
  Feedback.sentiment,
346
  db.func.count(Feedback.id).label('count')
347
  ).group_by(Feedback.sentiment).all()
348
  sentiment_stats = [{'sentiment': item.sentiment, 'count': item.count} for item in sentiment_stats]
349
 
 
350
  topic_stats = db.session.query(
351
  Feedback.topic,
352
  db.func.count(Feedback.id).label('count')
353
  ).group_by(Feedback.topic).all()
354
  topic_stats = [{'topic': item.topic, 'count': item.count} for item in topic_stats]
355
 
 
 
356
  seven_days_ago = datetime.now() - timedelta(days=7)
357
  daily_stats = db.session.query(
358
  db.func.date(Feedback.created_at).label('date'),
 
376
  @app.route("/api/feedback-history", methods=["GET"])
377
  @login_required
378
  def get_feedback_history():
 
379
  try:
380
  page = request.args.get('page', 1, type=int)
381
  per_page = request.args.get('per_page', 10, type=int)
 
383
  start_date = request.args.get('start_date', None, type=str)
384
  end_date = request.args.get('end_date', None, type=str)
385
 
 
386
  query = Feedback.query.filter_by(user_id=current_user.id)
387
 
 
388
  if time_filter != 'all':
389
  vietnam_now = utc_to_vietnam_time(datetime.utcnow())
390
 
391
  if time_filter == 'today':
 
392
  today_start = vietnam_now.replace(hour=0, minute=0, second=0, microsecond=0)
393
  today_start_utc = today_start.astimezone(pytz.utc).replace(tzinfo=None)
394
  query = query.filter(Feedback.created_at >= today_start_utc)
 
395
  elif time_filter == 'week':
 
396
  week_ago = vietnam_now - timedelta(days=7)
397
  week_ago_utc = week_ago.astimezone(pytz.utc).replace(tzinfo=None)
398
  query = query.filter(Feedback.created_at >= week_ago_utc)
 
399
  elif time_filter == 'month':
 
400
  month_ago = vietnam_now - timedelta(days=30)
401
  month_ago_utc = month_ago.astimezone(pytz.utc).replace(tzinfo=None)
402
  query = query.filter(Feedback.created_at >= month_ago_utc)
 
403
  elif time_filter == 'custom' and start_date and end_date:
 
404
  try:
405
  start_datetime = datetime.strptime(start_date, '%Y-%m-%d')
406
  end_datetime = datetime.strptime(end_date, '%Y-%m-%d')
 
 
407
  start_datetime_utc = VIETNAM_TIMEZONE.localize(start_datetime).astimezone(pytz.utc).replace(tzinfo=None)
408
  end_datetime_utc = VIETNAM_TIMEZONE.localize(end_datetime.replace(hour=23, minute=59, second=59)).astimezone(pytz.utc).replace(tzinfo=None)
409
+ query = query.filter(Feedback.created_at >= start_datetime_utc, Feedback.created_at <= end_datetime_utc)
 
 
410
  except ValueError:
411
  return jsonify({'error': 'Định dạng ngày không hợp lệ'}), 400
412
 
 
413
  total_count = query.count()
414
+ feedbacks = query.order_by(Feedback.created_at.desc()).paginate(page=page, per_page=per_page, error_out=False)
 
 
 
 
415
 
416
  feedback_list = []
417
  for feedback in feedbacks.items:
 
427
 
428
  return jsonify({
429
  'feedbacks': feedback_list,
430
+ 'total': total_count,
431
  'pages': feedbacks.pages,
432
  'current_page': page,
433
  'has_next': feedbacks.has_next,
 
446
  if not text:
447
  return jsonify({"error": "Missing 'text' field"}), 400
448
 
 
449
  if len(text) > 1000:
450
  return jsonify({"error": "Text quá dài. Vui lòng nhập tối đa 1000 ký tự."}), 400
451
 
452
+ if tokenizer is None or model is None:
453
+ return jsonify({"error": "Model or tokenizer not loaded. Please restart the application."}), 500
 
 
 
 
 
454
 
455
+ results = analyze_feedback(text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
456
 
 
457
  try:
458
+ save_feedback_to_db(text, results, current_user.id)
 
 
 
 
 
 
 
 
459
  db.session.commit()
 
 
460
  backup_database()
461
+ except Exception:
462
+ pass
 
 
463
 
464
  return jsonify({
465
+ "results": results,
466
+ "has_multiple_topics": len(results) > 1
 
 
 
 
467
  })
468
  except Exception as e:
469
  return jsonify({"error": f"Có lỗi xảy ra khi xử lý: {str(e)}"}), 500
 
471
  @app.route('/admin/backup', methods=['POST'])
472
  @admin_required
473
  def manual_backup():
 
474
  try:
475
  if backup_database():
476
  return jsonify({"success": True, "message": "Backup completed successfully"})
 
482
  @app.route('/admin/restore', methods=['POST'])
483
  @admin_required
484
  def manual_restore():
 
485
  try:
486
  if restore_database():
487
  return jsonify({"success": True, "message": "Database restored successfully"})
 
490
  except Exception as e:
491
  return jsonify({"success": False, "message": f"Restore error: {str(e)}"}), 500
492
 
 
493
  with app.app_context():
 
494
  db_manager.initialize_database_if_needed()
 
495
  db.create_all()
 
 
496
  db_manager.backup_database()
497
 
 
498
  try:
499
  db.session.execute(db.text("SELECT is_admin FROM users LIMIT 1"))
500
  except Exception:
 
504
  except Exception:
505
  pass
506
 
 
507
  try:
508
  total_users = User.query.count()
509
  admin_user = User.query.filter_by(username='admin').first()
 
510
  if not admin_user and total_users == 0:
511
  admin_user = User(username='admin', is_admin=True)
512
  admin_user.set_password('123456')
 
521
  @app.route("/analyze-csv", methods=["POST"])
522
  @login_required
523
  def analyze_csv():
 
524
  try:
525
  if 'csvFile' not in request.files:
526
  return jsonify({'error': 'Không tìm thấy file CSV'}), 400
 
532
  if not file.filename.lower().endswith('.csv'):
533
  return jsonify({'error': 'File phải có định dạng CSV'}), 400
534
 
 
535
  try:
 
536
  file_content = file.stream.read().decode("UTF8")
537
  except UnicodeDecodeError:
538
+ return jsonify({'error': 'File CSV phải được mã hóa UTF-8'}), 400
539
 
540
  try:
541
  stream = io.StringIO(file_content, newline=None)
542
  csv_input = csv.DictReader(stream)
543
 
 
544
  if not csv_input.fieldnames:
545
+ return jsonify({'error': 'File CSV không có header'}), 400
546
 
 
547
  feedback_column = None
548
  available_columns = []
549
  for col in csv_input.fieldnames:
 
554
 
555
  if not feedback_column:
556
  return jsonify({
557
+ 'error': f'Không tìm thấy cột chứa feedback. Các cột: {", ".join(available_columns)}'
558
  }), 400
559
 
 
560
  rows = list(csv_input)
561
  if not rows:
562
+ return jsonify({'error': 'File CSV không có dữ liệu'}), 400
 
563
  except csv.Error as e:
564
+ return jsonify({'error': f'File CSV không đúng định dạng: {str(e)}'}), 400
565
  except Exception as e:
566
  return jsonify({'error': f'Lỗi khi đọc file CSV: {str(e)}'}), 400
567
 
 
568
  results = []
569
  processed_count = 0
570
  error_count = 0
571
 
572
+ for row_num, row in enumerate(rows, start=1):
573
  feedback_text = row[feedback_column].strip()
574
 
575
  if not feedback_text:
 
582
  continue
583
 
584
  try:
585
+ if tokenizer is None or model is None:
 
586
  results.append({
587
  "row": row_num,
588
  "feedback": feedback_text,
589
+ "error": "Model or tokenizer not loaded"
 
 
 
 
590
  })
591
  continue
 
 
 
592
 
593
+ row_topics = analyze_feedback(feedback_text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
594
 
 
 
 
 
595
  try:
596
+ save_feedback_to_db(feedback_text, row_topics, current_user.id)
597
+
598
+ if row_topics:
599
+ first = row_topics[0]
600
+ first_topic = first['topic']
601
+ first_sentiment = first['sentiment']
602
+ first_sentiment_conf = first.get('sentiment_confidence', first['confidence'])
603
+ first_topic_conf = first['confidence']
604
+ else:
605
+ first_topic = 'others'
606
+ first_sentiment = 'neutral'
607
+ first_sentiment_conf = 0.0
608
+ first_topic_conf = 0.0
609
 
610
  results.append({
611
  'row': row_num,
612
  'text': feedback_text[:100] + '...' if len(feedback_text) > 100 else feedback_text,
613
+ 'sentiment': first_sentiment,
614
+ 'topic': first_topic,
615
+ 'sentiment_confidence': round(first_sentiment_conf * 100, 1),
616
+ 'topic_confidence': round(first_topic_conf * 100, 1),
617
  'success': True
618
  })
 
619
  processed_count += 1
620
+ except Exception as db_err:
 
 
621
  error_count += 1
622
  results.append({
623
  'row': row_num,
624
  'text': feedback_text[:100] + '...' if len(feedback_text) > 100 else feedback_text,
625
+ 'error': f'Lỗi lưu database: {str(db_err)}'
626
  })
627
 
628
  except Exception as e:
 
633
  'error': f'Lỗi phân tích: {str(e)}'
634
  })
635
 
 
636
  try:
637
  db.session.commit()
 
 
 
638
  backup_database()
 
639
  except Exception as commit_error:
640
  db.session.rollback()
641
+ return jsonify({'error': f'Lỗi khi lưu dữ liệu: {str(commit_error)}'}), 500
 
 
 
642
 
643
  return jsonify({
644
  'success': True,
645
  'total_rows': len(results),
646
  'processed_count': processed_count,
647
  'error_count': error_count,
648
+ 'results': results[:50],
649
  'message': f'Đã xử lý {processed_count}/{len(results)} feedback thành công'
650
  })
651
 
 
656
  }), 500
657
 
658
  if __name__ == "__main__":
 
659
  debug = os.environ.get("DEBUG", "False").lower() == "true"
660
  app.run(host="0.0.0.0", port=7860, debug=debug)
database_manager.py CHANGED
@@ -4,31 +4,20 @@ import json
4
  from datetime import datetime
5
  from typing import Optional
6
  from huggingface_hub import HfApi, login
7
- from datasets import Dataset
8
  import sqlite3
9
  import tempfile
10
 
11
  class DatabaseManager:
12
  def __init__(self, hf_token: Optional[str] = None, repo_id: Optional[str] = None):
13
- """
14
- Initialize Database Manager for Hugging Face Hub storage
15
-
16
- Args:
17
- hf_token: Hugging Face token (can be set via environment variable HF_TOKEN)
18
- repo_id: Hugging Face repository ID for storing the database
19
- """
20
  self.repo_id = repo_id or os.getenv('REPO_ID', 'your-username/student-feedback-db')
21
  self.hf_token = hf_token or os.getenv('HF_TOKEN')
22
  self.db_path = 'instance/feedback_analysis.db'
23
  self.backup_dir = 'backups'
24
-
25
- # Check if running locally (no HF_TOKEN)
26
  self.is_local = not self.hf_token
27
 
28
- # Create backup directory if it doesn't exist
29
  os.makedirs(self.backup_dir, exist_ok=True)
30
 
31
-
32
  if self.hf_token:
33
  try:
34
  login(token=self.hf_token)
@@ -39,12 +28,11 @@ class DatabaseManager:
39
  self.api = None
40
 
41
  def sqlite_to_json(self, db_path: str) -> dict:
42
- """Convert SQLite database to JSON format for Hugging Face Dataset"""
43
  try:
44
  conn = sqlite3.connect(db_path)
45
  cursor = conn.cursor()
46
 
47
- # Get all tables
48
  cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
49
  tables = cursor.fetchall()
50
 
@@ -53,28 +41,20 @@ class DatabaseManager:
53
  for table in tables:
54
  table_name = table[0]
55
 
56
- # Skip system tables
57
  if table_name in ['sqlite_sequence', 'sqlite_master']:
58
  continue
59
 
60
- # Get table schema
61
  cursor.execute(f"PRAGMA table_info({table_name})")
62
  columns = [col[1] for col in cursor.fetchall()]
63
 
64
- # Get all data from table
65
  cursor.execute(f"SELECT * FROM {table_name}")
66
  rows = cursor.fetchall()
67
 
68
- # Convert to list of dictionaries
69
  table_data = []
70
  for row in rows:
71
  row_dict = {}
72
  for i, value in enumerate(row):
73
- # Convert datetime objects to strings
74
- if isinstance(value, datetime):
75
- value = value.isoformat()
76
- # Convert boolean fields
77
- elif columns[i] == 'is_admin':
78
  value = bool(value) if value is not None else False
79
  row_dict[columns[i]] = value
80
  table_data.append(row_dict)
@@ -84,7 +64,6 @@ class DatabaseManager:
84
  'data': table_data
85
  }
86
 
87
- # Handle sqlite_sequence separately
88
  try:
89
  cursor.execute("SELECT * FROM sqlite_sequence")
90
  sequence_rows = cursor.fetchall()
@@ -100,31 +79,25 @@ class DatabaseManager:
100
  'data': sequence_data
101
  }
102
  except Exception:
103
- # sqlite_sequence might not exist, that's ok
104
  pass
105
 
106
  conn.close()
107
  return data
108
-
109
- except Exception as e:
110
- print(f"❌ Error converting SQLite to JSON: {e}")
111
  return {}
112
 
113
  def json_to_sqlite(self, json_data: dict, db_path: str):
114
  """Convert JSON data back to SQLite database"""
115
  try:
116
- # Remove existing database if it exists
117
  if os.path.exists(db_path):
118
  os.remove(db_path)
119
 
120
- # Create directory if it doesn't exist
121
  os.makedirs(os.path.dirname(db_path), exist_ok=True)
122
 
123
  conn = sqlite3.connect(db_path)
124
  cursor = conn.cursor()
125
 
126
  for table_name, table_info in json_data.items():
127
- # Skip system tables
128
  if table_name in ['sqlite_sequence', 'sqlite_master']:
129
  continue
130
 
@@ -134,7 +107,6 @@ class DatabaseManager:
134
  if not columns:
135
  continue
136
 
137
- # Create table
138
  column_defs = []
139
  for col in columns:
140
  if col == 'id':
@@ -147,7 +119,6 @@ class DatabaseManager:
147
  create_sql = f"CREATE TABLE IF NOT EXISTS {table_name} ({', '.join(column_defs)})"
148
  cursor.execute(create_sql)
149
 
150
- # Insert data
151
  if data:
152
  placeholders = ', '.join(['?' for _ in columns])
153
  insert_sql = f"INSERT INTO {table_name} ({', '.join(columns)}) VALUES ({placeholders})"
@@ -156,7 +127,6 @@ class DatabaseManager:
156
  values = []
157
  for col in columns:
158
  value = row.get(col)
159
- # Convert boolean fields properly
160
  if col == 'is_admin':
161
  if isinstance(value, bool):
162
  values.append(int(value))
@@ -168,7 +138,6 @@ class DatabaseManager:
168
  values.append(value)
169
  cursor.execute(insert_sql, values)
170
 
171
- # Handle sqlite_sequence separately if it exists in backup
172
  if 'sqlite_sequence' in json_data:
173
  sequence_data = json_data['sqlite_sequence']['data']
174
  for seq_row in sequence_data:
@@ -180,7 +149,6 @@ class DatabaseManager:
180
 
181
  conn.commit()
182
  conn.close()
183
-
184
  except Exception:
185
  pass
186
 
@@ -193,21 +161,17 @@ class DatabaseManager:
193
  return False
194
 
195
  try:
196
- # Convert database to JSON
197
  json_data = self.sqlite_to_json(self.db_path)
198
 
199
  if not json_data:
200
  return False
201
 
202
-
203
- # Create temporary file for JSON data
204
  timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
205
  temp_file = f"{self.backup_dir}/feedback_backup_{timestamp}.json"
206
 
207
  with open(temp_file, 'w', encoding='utf-8') as f:
208
  json.dump(json_data, f, indent=2, ensure_ascii=False)
209
 
210
- # Upload to Hugging Face Hub
211
  try:
212
  self.api.upload_file(
213
  path_or_fileobj=temp_file,
@@ -218,14 +182,12 @@ class DatabaseManager:
218
  )
219
  except Exception as upload_error:
220
  if "No files have been modified" in str(upload_error):
221
- return True # This is actually success
222
  else:
223
  raise upload_error
224
 
225
- # Clean up temporary file
226
  os.remove(temp_file)
227
  return True
228
-
229
  except Exception:
230
  return False
231
 
@@ -235,7 +197,6 @@ class DatabaseManager:
235
  return False
236
 
237
  try:
238
- # Download latest backup
239
  temp_dir = tempfile.mkdtemp()
240
  temp_file = os.path.join(temp_dir, 'feedback_backup.json')
241
 
@@ -246,17 +207,12 @@ class DatabaseManager:
246
  repo_type="dataset"
247
  )
248
 
249
- # Convert JSON back to SQLite
250
  with open(temp_file, 'r', encoding='utf-8') as f:
251
  json_data = json.load(f)
252
 
253
-
254
  self.json_to_sqlite(json_data, self.db_path)
255
-
256
- # Clean up
257
  shutil.rmtree(temp_dir)
258
  return True
259
-
260
  except Exception:
261
  return False
262
 
 
4
  from datetime import datetime
5
  from typing import Optional
6
  from huggingface_hub import HfApi, login
 
7
  import sqlite3
8
  import tempfile
9
 
10
  class DatabaseManager:
11
  def __init__(self, hf_token: Optional[str] = None, repo_id: Optional[str] = None):
12
+ """Initialize Database Manager for Hugging Face Hub storage"""
 
 
 
 
 
 
13
  self.repo_id = repo_id or os.getenv('REPO_ID', 'your-username/student-feedback-db')
14
  self.hf_token = hf_token or os.getenv('HF_TOKEN')
15
  self.db_path = 'instance/feedback_analysis.db'
16
  self.backup_dir = 'backups'
 
 
17
  self.is_local = not self.hf_token
18
 
 
19
  os.makedirs(self.backup_dir, exist_ok=True)
20
 
 
21
  if self.hf_token:
22
  try:
23
  login(token=self.hf_token)
 
28
  self.api = None
29
 
30
  def sqlite_to_json(self, db_path: str) -> dict:
31
+ """Convert SQLite database to JSON format"""
32
  try:
33
  conn = sqlite3.connect(db_path)
34
  cursor = conn.cursor()
35
 
 
36
  cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
37
  tables = cursor.fetchall()
38
 
 
41
  for table in tables:
42
  table_name = table[0]
43
 
 
44
  if table_name in ['sqlite_sequence', 'sqlite_master']:
45
  continue
46
 
 
47
  cursor.execute(f"PRAGMA table_info({table_name})")
48
  columns = [col[1] for col in cursor.fetchall()]
49
 
 
50
  cursor.execute(f"SELECT * FROM {table_name}")
51
  rows = cursor.fetchall()
52
 
 
53
  table_data = []
54
  for row in rows:
55
  row_dict = {}
56
  for i, value in enumerate(row):
57
+ if columns[i] == 'is_admin':
 
 
 
 
58
  value = bool(value) if value is not None else False
59
  row_dict[columns[i]] = value
60
  table_data.append(row_dict)
 
64
  'data': table_data
65
  }
66
 
 
67
  try:
68
  cursor.execute("SELECT * FROM sqlite_sequence")
69
  sequence_rows = cursor.fetchall()
 
79
  'data': sequence_data
80
  }
81
  except Exception:
 
82
  pass
83
 
84
  conn.close()
85
  return data
86
+ except Exception:
 
 
87
  return {}
88
 
89
  def json_to_sqlite(self, json_data: dict, db_path: str):
90
  """Convert JSON data back to SQLite database"""
91
  try:
 
92
  if os.path.exists(db_path):
93
  os.remove(db_path)
94
 
 
95
  os.makedirs(os.path.dirname(db_path), exist_ok=True)
96
 
97
  conn = sqlite3.connect(db_path)
98
  cursor = conn.cursor()
99
 
100
  for table_name, table_info in json_data.items():
 
101
  if table_name in ['sqlite_sequence', 'sqlite_master']:
102
  continue
103
 
 
107
  if not columns:
108
  continue
109
 
 
110
  column_defs = []
111
  for col in columns:
112
  if col == 'id':
 
119
  create_sql = f"CREATE TABLE IF NOT EXISTS {table_name} ({', '.join(column_defs)})"
120
  cursor.execute(create_sql)
121
 
 
122
  if data:
123
  placeholders = ', '.join(['?' for _ in columns])
124
  insert_sql = f"INSERT INTO {table_name} ({', '.join(columns)}) VALUES ({placeholders})"
 
127
  values = []
128
  for col in columns:
129
  value = row.get(col)
 
130
  if col == 'is_admin':
131
  if isinstance(value, bool):
132
  values.append(int(value))
 
138
  values.append(value)
139
  cursor.execute(insert_sql, values)
140
 
 
141
  if 'sqlite_sequence' in json_data:
142
  sequence_data = json_data['sqlite_sequence']['data']
143
  for seq_row in sequence_data:
 
149
 
150
  conn.commit()
151
  conn.close()
 
152
  except Exception:
153
  pass
154
 
 
161
  return False
162
 
163
  try:
 
164
  json_data = self.sqlite_to_json(self.db_path)
165
 
166
  if not json_data:
167
  return False
168
 
 
 
169
  timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
170
  temp_file = f"{self.backup_dir}/feedback_backup_{timestamp}.json"
171
 
172
  with open(temp_file, 'w', encoding='utf-8') as f:
173
  json.dump(json_data, f, indent=2, ensure_ascii=False)
174
 
 
175
  try:
176
  self.api.upload_file(
177
  path_or_fileobj=temp_file,
 
182
  )
183
  except Exception as upload_error:
184
  if "No files have been modified" in str(upload_error):
185
+ return True
186
  else:
187
  raise upload_error
188
 
 
189
  os.remove(temp_file)
190
  return True
 
191
  except Exception:
192
  return False
193
 
 
197
  return False
198
 
199
  try:
 
200
  temp_dir = tempfile.mkdtemp()
201
  temp_file = os.path.join(temp_dir, 'feedback_backup.json')
202
 
 
207
  repo_type="dataset"
208
  )
209
 
 
210
  with open(temp_file, 'r', encoding='utf-8') as f:
211
  json_data = json.load(f)
212
 
 
213
  self.json_to_sqlite(json_data, self.db_path)
 
 
214
  shutil.rmtree(temp_dir)
215
  return True
 
216
  except Exception:
217
  return False
218
 
forms.py CHANGED
@@ -1,5 +1,5 @@
1
  from flask_wtf import FlaskForm
2
- from wtforms import StringField, PasswordField, SubmitField, TextAreaField
3
  from wtforms.validators import DataRequired, Length, EqualTo, ValidationError
4
  from models import User
5
 
@@ -37,10 +37,3 @@ class LoginForm(FlaskForm):
37
 
38
  submit = SubmitField('Đăng nhập')
39
 
40
- class FeedbackForm(FlaskForm):
41
- text = TextAreaField('Feedback của bạn', validators=[
42
- DataRequired(message='Vui lòng nhập feedback'),
43
- Length(min=10, max=1000, message='Feedback phải từ 10-1000 ký tự')
44
- ])
45
-
46
- submit = SubmitField('Phân Tích Feedback')
 
1
  from flask_wtf import FlaskForm
2
+ from wtforms import StringField, PasswordField, SubmitField
3
  from wtforms.validators import DataRequired, Length, EqualTo, ValidationError
4
  from models import User
5
 
 
37
 
38
  submit = SubmitField('Đăng nhập')
39
 
 
 
 
 
 
 
 
model_config.py ADDED
@@ -0,0 +1,342 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration for PhoBERT Pair-ABSA Model"""
2
+
3
+ import unicodedata
4
+ import re
5
+
6
+ BASE_MODEL = "vinai/phobert-base"
7
+ MAX_LEN = 256
8
+ PRED_THRESHOLD = 0.60
9
+ NUM_CLASSES = 4
10
+ DROPOUT = 0.3
11
+
12
+ MIN_SENT_PROB = 0.50
13
+ MIN_MARGIN = 0.08
14
+
15
+
16
+ ASPECTS_VI = ["giang_vien", "chuong_trinh", "co_so_vat_chat", "khac"]
17
+ ASPECTS_EN = ["lecturer", "training_program", "facility", "others"]
18
+
19
+ ASPECT_MAPPING = {
20
+ "giang_vien": "lecturer",
21
+ "chuong_trinh": "training_program",
22
+ "co_so_vat_chat": "facility",
23
+ "khac": "others"
24
+ }
25
+
26
+ ASPECT_REVERSE_MAPPING = {v: k for k, v in ASPECT_MAPPING.items()}
27
+
28
+ LABEL_MAP = {
29
+ 0: "none",
30
+ 1: "negative",
31
+ 2: "neutral",
32
+ 3: "positive"
33
+ }
34
+
35
+ def _norm_store(s: str) -> str:
36
+ """Chuẩn hoá để lưu/dedup (giữ dấu)"""
37
+ s = unicodedata.normalize("NFC", str(s)).strip()
38
+ s = re.sub(r"\s+", " ", s)
39
+ return s
40
+
41
+ def _norm_match(s: str) -> str:
42
+ """Chuẩn hoá để match keyword (bỏ dấu + lower)"""
43
+ s = s.lower()
44
+ s = unicodedata.normalize("NFD", s)
45
+ return "".join(ch for ch in s if unicodedata.category(ch) != "Mn")
46
+
47
+ def _no_diacritics_set(kws: set) -> set:
48
+ """Build keyword set có & không dấu"""
49
+ return kws | {_norm_match(k) for k in kws}
50
+
51
+ ASPECT_PROMPTS = {
52
+ "giang_vien": {
53
+ "_default": (
54
+ "ĐÁNH GIÁ phần liên quan GIẢNG VIÊN (giảng dạy, thái độ, hỗ trợ, chấm điểm, đúng giờ). Nếu câu không nhắc rõ đến GIẢNG VIÊN -> NONE. Mỗi aspect đánh giá độc lập (ví dụ: giảng viên đi dạy trễ nhưng mạng wifi tốt -> giảng viên NEGATIVE, cơ sở vật chất POSITIVE). NEGATIVE khi phàn nàn trễ, khó hiểu, thiếu hỗ trợ; POSITIVE khi được khen đúng giờ, nhiệt tình, dễ hiểu; không rõ -> NEUTRAL."
55
+ ),
56
+ "giang_day": (
57
+ "ĐÁNH GIÁ GIẢNG DẠY của GIẢNG VIÊN. Nếu câu không nói về bài giảng, cách truyền đạt, phương pháp -> NONE. Mỗi aspect độc lập. NEGATIVE khi khó hiểu, quá nhanh/chậm, thiếu ví dụ; POSITIVE khi dễ hiểu, nhiều ví dụ, rõ ràng; không rõ -> NEUTRAL."
58
+ ),
59
+ "dung_gio": (
60
+ "ĐÁNH GIÁ ĐÚNG GIỜ của GIẢNG VIÊN. Nếu câu không nhắc việc vào lớp, bắt đầu/kết thúc tiết -> NONE. Mỗi aspect độc lập. NEGATIVE khi trễ, bỏ tiết; POSITIVE khi đúng giờ, giữ lịch; không rõ -> NEUTRAL."
61
+ ),
62
+ "ho_tro": (
63
+ "ĐÁNH GIÁ HỖ TRỢ/TƯ VẤN của GIẢNG VIÊN. Nếu câu không nhắc hỗ trợ, phản hồi, giải đáp -> NONE. Mỗi aspect độc lập. NEGATIVE khi chậm phản hồi, không giúp; POSITIVE khi nhiệt tình, phản hồi nhanh; không rõ -> NEUTRAL."
64
+ ),
65
+ "cham_diem": (
66
+ "ĐÁNH GIÁ CHẤM ĐIỂM của GIẢNG VIÊN. Nếu câu không nói điểm, rubric, phúc khảo -> NONE. Mỗi aspect độc lập. NEGATIVE khi không công bằng, khó hiểu; POSITIVE khi minh bạch, công bằng; không rõ -> NEUTRAL."
67
+ ),
68
+ "thai_do": (
69
+ "ĐÁNH GIÁ THÁI ĐỘ/TÁC PHONG của GIẢNG VIÊN. Nếu câu không nhắc thái độ, giao tiếp -> NONE. Mỗi aspect độc lập. NEGATIVE khi thô lỗ, thiếu tôn trọng; POSITIVE khi thân thiện, tôn trọng; không rõ -> NEUTRAL."
70
+ ),
71
+ },
72
+ "chuong_trinh": {
73
+ "_default": (
74
+ "ĐÁNH GIÁ CHƯƠNG TRÌNH ĐÀO TẠO (môn học, tín chỉ, nội dung, lộ trình, lịch). Nếu câu không nhắc rõ đến chương trình -> NONE. Mỗi aspect đánh giá độc lập (ví dụ: lịch học dày nhưng giảng viên hỗ trợ tốt -> chương trình NEGATIVE, giảng viên POSITIVE). NEGATIVE khi quá tải, lạc hậu, trùng lặp; POSITIVE khi hợp lý, cập nhật, thực tế; không rõ -> NEUTRAL."
75
+ ),
76
+ "noi_dung": (
77
+ "ĐÁNH GIÁ NỘI DUNG CHƯƠNG TRÌNH. Nếu câu không nói nội dung môn, học liệu, lộ trình -> NONE. Mỗi aspect độc lập. NEGATIVE khi lạc hậu, trùng lặp, thiếu thực tế; POSITIVE khi cập nhật, hữu ích; không rõ -> NEUTRAL."
78
+ ),
79
+ "lich_hoc": (
80
+ "ĐÁNH GIÁ LỊCH HỌC/KẾ HOẠCH. Nếu câu không nhắc lịch, thời khóa biểu, xếp ca -> NONE. Mỗi aspect độc lập. NEGATIVE khi dồn dập, trùng lịch, đổi lịch liên tục; POSITIVE khi rõ ràng, hợp lý; không rõ -> NEUTRAL."
81
+ ),
82
+ "tin_chi": (
83
+ "ĐÁNH GIÁ TÍN CHỈ/HỌC PHẦN. Nếu câu không nói tín chỉ, đăng ký học phần, tiên quyết -> NONE. Mỗi aspect độc lập. NEGATIVE khi bất hợp lý, khó đăng ký; POSITIVE khi phân bổ hợp lý, dễ đăng ký; không rõ -> NEUTRAL."
84
+ ),
85
+ "de_cuong": (
86
+ "ĐÁNH GIÁ ĐỀ CƯƠNG/GIÁO TRÌNH. Nếu câu không nhắc đề cương, tài liệu, rubric -> NONE. Mỗi aspect độc lập. NEGATIVE khi thiếu rõ ràng, thiếu tài liệu; POSITIVE khi đầy đủ, minh bạch; không rõ -> NEUTRAL."
87
+ ),
88
+ },
89
+ "co_so_vat_chat": {
90
+ "_default": (
91
+ "ĐÁNH GIÁ CƠ SỞ VẬT CHẤT (mạng, phòng học, phòng thí nghiệm, thiết bị, thư viện, gửi xe, vệ sinh, cổng đào tạo). Nếu câu không nhắc rõ đến cơ sở vật chất -> NONE. Mỗi aspect đánh giá độc lập (ví dụ: phòng học nóng nhưng thầy cô dạy dễ hiểu -> cơ sở vật chất NEGATIVE, giảng viên POSITIVE). NEGATIVE khi phàn nàn hỏng, thiếu, bẩn; POSITIVE khi khen đầy đủ, sạch, hiện đại; không rõ -> NEUTRAL."
92
+ ),
93
+ "mang": (
94
+ "ĐÁNH GIÁ MẠNG/WI-FI. Nếu câu không nói mạng, wifi, internet -> NONE. Mỗi aspect độc lập. NEGATIVE khi chậm, rớt kết nối; POSITIVE khi nhanh, ổn định; không rõ -> NEUTRAL."
95
+ ),
96
+ "phong_hoc": (
97
+ "ĐÁNH GIÁ PHÒNG HỌC. Nếu câu không nói phòng học, bàn ghế, điều hòa, tiếng ồn -> NONE. Mỗi aspect độc lập. NEGATIVE khi nóng, ồn, xuống cấp; POSITIVE khi mát, sạch, đủ tiện nghi; không rõ -> NEUTRAL."
98
+ ),
99
+ "phong_thi_nghiem": (
100
+ "ĐÁNH GIÁ PHÒNG THÍ NGHIỆM/THỰC HÀNH. Nếu câu không nhắc lab, thiết bị thực hành -> NONE. Mỗi aspect độc lập. NEGATIVE khi thiếu máy, phần mềm lỗi; POSITIVE khi đầy đủ, hiện đại; không rõ -> NEUTRAL."
101
+ ),
102
+ "thiet_bi": (
103
+ "ĐÁNH GIÁ THIẾT BỊ GIẢNG DẠY. Nếu câu không nói máy chiếu, micro, loa, TV -> NONE. Mỗi aspect độc lập. NEGATIVE khi hỏng, âm kém; POSITIVE khi hoạt động tốt, rõ ràng; không rõ -> NEUTRAL."
104
+ ),
105
+ "thu_vien": (
106
+ "ĐÁNH GIÁ THƯ VIỆN. Nếu câu không nhắc thư viện, tài liệu, chỗ ngồi -> NONE. Mỗi aspect độc lập. NEGATIVE khi thiếu tài liệu, chật, ồn; POSITIVE khi phong phú, yên tĩnh; không rõ -> NEUTRAL."
107
+ ),
108
+ "giu_xe_ve_sinh": (
109
+ "ĐÁNH GIÁ GIỮ XE/NHÀ VỆ SINH. Nếu câu không nói gửi xe hoặc nhà vệ sinh -> NONE. Mỗi aspect độc lập. NEGATIVE khi bẩn, đắt, mùi khó chịu; POSITIVE khi sạch, thuận tiện; không rõ -> NEUTRAL."
110
+ ),
111
+ "cong_quan_ly_dao_tao": (
112
+ "ĐÁNH GIÁ CỔNG/TRANG QUẢN LÝ ĐÀO TẠO. Nếu câu không nhắc cổng đào tạo, đăng nhập, tra cứu -> NONE. Mỗi aspect độc lập. NEGATIVE khi quá tải, treo, khó dùng; POSITIVE khi ổn định, dễ dùng; không rõ -> NEUTRAL."
113
+ ),
114
+ },
115
+ "khac": {
116
+ "_default": (
117
+ "ĐÁNH GIÁ NHÓM KHÁC (học phí, học bổng, hành chính, CLB, KTX, một cửa, đăng ký tín chỉ, điểm rèn luyện). Nếu câu không nhắc rõ đến nhóm này -> NONE. Mỗi aspect đánh giá độc lập (ví dụ: học phí tăng nhưng phòng học tốt -> nhóm khác NEGATIVE, cơ sở vật chất POSITIVE). NEGATIVE khi phàn nàn khó khăn, chậm trễ; POSITIVE khi khen rõ ràng, nhanh chóng; không rõ -> NEUTRAL."
118
+ ),
119
+ "hoc_phi": (
120
+ "ĐÁNH GIÁ HỌC PHÍ. Nếu câu không nhắc học phí, mức thu, đóng tiền -> NONE. Mỗi aspect độc lập. NEGATIVE khi đắt, tăng, thiếu minh bạch; POSITIVE khi hợp lý, minh bạch; không rõ -> NEUTRAL."
121
+ ),
122
+ "hoc_bong": (
123
+ "ĐÁNH GIÁ HỌC BỔNG. Nếu câu không nói tiêu chí, quy trình, kết quả học bổng -> NONE. Mỗi aspect độc lập. NEGATIVE khi khó, chậm, không rõ; POSITIVE khi dễ, minh bạch, kịp thời; không rõ -> NEUTRAL."
124
+ ),
125
+ "hanh_chinh": (
126
+ "ĐÁNH GIÁ THỦ TỤC HÀNH CHÍNH/CTSV. Nếu câu không nhắc hồ sơ, giấy tờ, xử lý -> NONE. Mỗi aspect độc lập. NEGATIVE khi rườm rà, chậm, thiếu phản hồi; POSITIVE khi nhanh, rõ ràng; không rõ -> NEUTRAL."
127
+ ),
128
+ "clb": (
129
+ "ĐÁNH GIÁ CLB/HOẠT ĐỘNG NGOẠI KHÓA. Nếu câu không nói CLB, sự kiện, hoạt động sinh viên -> NONE. Mỗi aspect độc lập. NEGATIVE khi ít hoạt động, thiếu hấp dẫn; POSITIVE khi sôi nổi, hữu ích; không rõ -> NEUTRAL."
130
+ ),
131
+ "ktx": (
132
+ "ĐÁNH GIÁ KÝ TÚC XÁ. Nếu câu không nhắc phòng KTX, an ninh, điện nước -> NONE. Mỗi aspect độc lập. NEGATIVE khi chật, mất an ninh, thiếu điện nước; POSITIVE khi sạch, an toàn, đầy đủ; không rõ -> NEUTRAL."
133
+ ),
134
+ "mot_cua": (
135
+ "ĐÁNH GIÁ VĂN PHÒNG MỘT CỬA. Nếu câu không nhắc một cửa, tiếp nhận, trả kết quả -> NONE. Mỗi aspect độc lập. NEGATIVE khi chờ lâu, đông, xử lý chậm; POSITIVE khi nhanh, rõ ràng; không rõ -> NEUTRAL."
136
+ ),
137
+ "dang_ky_tin": (
138
+ "ĐÁNH GIÁ ĐĂNG KÝ TÍN CHỈ. Nếu câu không nói đăng ký môn, hệ thống đăng ký -> NONE. Mỗi aspect độc lập. NEGATIVE khi quá tải, lỗi, khó dùng; POSITIVE khi ổn định, dễ dùng; không rõ -> NEUTRAL."
139
+ ),
140
+ "diem_ren_luyen": (
141
+ "ĐÁNH GIÁ ĐIỂM RÈN LUYỆN. Nếu câu không nhắc DRL, minh chứng, quy trình -> NONE. Mỗi aspect độc lập. NEGATIVE khi khó, không công bằng; POSITIVE khi rõ ràng, công bằng; không rõ -> NEUTRAL."
142
+ ),
143
+ },
144
+ }
145
+ SUBTOPIC_KW = {
146
+ "giang_vien": {
147
+ "dung_gio": _no_diacritics_set({
148
+ "đi dạy","lên lớp","vào lớp","bắt đầu tiết","kết thúc tiết",
149
+ "giảng viên","giáo viên","thầy giáo","cô giáo","thầy cô",
150
+ "giảng viên đi dạy","giảng viên lên lớp","giảng viên vào lớp",
151
+ "thầy đi dạy","cô đi dạy","thầy lên lớp","cô lên lớp"
152
+ }),
153
+ "cham_diem": _no_diacritics_set({
154
+ "chấm điểm","thang điểm","điểm thi","điểm thành phần","điểm tổng kết","phúc khảo",
155
+ "điểm giữa kỳ","điểm cuối kỳ","điểm nhóm","điểm cá nhân","điểm bonus",
156
+ "điểm chuyên cần","điểm chuyên đề","rubric","grading",
157
+ "giảng viên","giáo viên","thầy giáo","cô giáo",
158
+ "giảng viên chấm điểm","thầy chấm điểm","cô chấm điểm","giáo viên chấm điểm",
159
+ "thầy giáo chấm điểm","cô giáo chấm điểm"
160
+ }),
161
+ "ho_tro": _no_diacritics_set({
162
+ "tư vấn học tập","giải đáp học tập","phản hồi học tập","cvht",
163
+ "cố vấn học tập","hướng dẫn học tập","trao đổi học tập","hỏi đáp học tập",
164
+ "tư vấn sinh viên","giải đáp sinh viên","phản hồi sinh viên",
165
+ "cố vấn sinh viên","hướng dẫn sinh viên",
166
+ "giảng viên","giáo viên","thầy giáo","cô giáo",
167
+ "giảng viên tư vấn","giảng viên hướng dẫn","giảng viên giải đáp",
168
+ "thầy tư vấn","cô tư vấn","thầy hướng dẫn","cô hướng dẫn",
169
+ "thầy giáo tư vấn","cô giáo tư vấn"
170
+ }),
171
+ "thai_do": _no_diacritics_set({
172
+ "thái độ","ứng xử","tác phong","phong thái","giao tiếp","cách nói",
173
+ "ngữ điệu","hành vi","cử chỉ","cách cư xử","thái độ giảng viên",
174
+ "phong cách","tương tác","thái độ lớp","ngôn ngữ cơ thể",
175
+ "giảng viên","giáo viên","thầy giáo","cô giáo",
176
+ "thái độ thầy","thái độ cô","thái độ giáo viên",
177
+ "thầy giáo thái độ","cô giáo thái độ","giảng viên thái độ"
178
+ }),
179
+ "giang_day": _no_diacritics_set({
180
+ "giảng dạy","truyền đạt","diễn đạt","ví dụ","bài giảng","slide","ghi chú",
181
+ "ôn tập","bài học","phương pháp","thực hành","lý thuyết",
182
+ "thảo luận","minh họa","slide giảng","slide bài","giải thích","phong cách giảng",
183
+ "giảng viên","giáo viên","thầy giáo","cô giáo",
184
+ "giảng viên giảng dạy","thầy giảng","cô giảng","giáo viên giảng dạy",
185
+ "thầy giáo giảng dạy","cô giáo giảng dạy"
186
+ }),
187
+ },
188
+
189
+ "chuong_trinh": {
190
+ "lich_hoc": _no_diacritics_set({
191
+ "lịch học","thời khóa biểu","thời khoá biểu","kế hoạch học tập","xếp lịch","trùng lịch",
192
+ "đổi lịch","lịch thi","lịch học thêm","ca tối","online","offline","ca sáng",
193
+ "ca chiều","học bù","thi dồn","thi liên tục","xếp ca","thời gian học","lịch kiểm tra"
194
+ }),
195
+ "tin_chi": _no_diacritics_set({
196
+ "tín chỉ","học phần","tiên quyết","song hành","đăng ký học phần","nợ môn",
197
+ "đủ tín","số tín","khối lượng học","điều kiện học phần","mã môn","tải học",
198
+ "phân bổ học phần","lộ trình học","số học phần"
199
+ }),
200
+ "de_cuong": _no_diacritics_set({
201
+ "đề cương","syllabus","giáo trình","tài liệu bắt buộc môn học","tài liệu tham khảo môn học",
202
+ "mục tiêu học phần","kế hoạch môn","outline","kế hoạch giảng dạy","phân bổ điểm",
203
+ "tài liệu học môn học","hướng dẫn môn học","phân phối chương trình","khung điểm môn học","thang đánh giá môn học"
204
+ }),
205
+ "noi_dung": _no_diacritics_set({
206
+ "nội dung","thực tế","thực tiễn","lộ trình","khung chương trình",
207
+ "cập nhật","định hướng nghề","kiến thức","module",
208
+ "chuyên đề","cấu trúc môn","chương trình học","đề mục","môn học","học liệu"
209
+ }),
210
+ },
211
+
212
+ "co_so_vat_chat": {
213
+ "mang": _no_diacritics_set({
214
+ "mạng wifi","wifi","wi-fi","wi fi","đăng nhập wifi", "ping wifi","băng thông wifi","wifi trường"
215
+ }),
216
+ "phong_hoc": _no_diacritics_set({
217
+ "phòng học","ánh sáng","đèn phòng học","máy lạnh","điều hòa","điều hoà","quạt",
218
+ "bàn ghế phòng học","ổ điện phòng học","ổ cắm phòng học","cách âm","sàn nhà","rèm cửa","trần nhà","bảng viết"
219
+ }),
220
+ "phong_thi_nghiem": _no_diacritics_set({
221
+ "phòng thí nghiệm","phòng thực hành","lab","phòng lab","máy thực hành",
222
+ "cài phần mềm","thiết bị thí nghiệm","dụng cụ lab","phòng máy","thiết bị lab"
223
+ }),
224
+ "thiet_bi": _no_diacritics_set({
225
+ "máy chiếu","micro","mic","loa","âm thanh","tivi","cáp","hdmi","adapter",
226
+ "thiết bị giảng dạy","máy quay","camera lớp","loa bluetooth","âm lượng",
227
+ "đầu nối","bộ chia","thiết bị phòng học","tv phòng học"
228
+ }),
229
+ "thu_vien": _no_diacritics_set({
230
+ "thư viện","mượn sách","trả sách","tài liệu số thư viện","ebook thư viện","chỗ ngồi thư viện",
231
+ "bàn đọc","yên tĩnh thư viện","giờ mở cửa thư viện","mượn giáo trình","tra cứu sách","wifi thư viện",
232
+ "tra cứu thư viện","kệ sách","tài nguyên số thư viện","khu đọc","mượn tài liệu thư viện","mượn thiết bị thư viện",
233
+ "tài liệu thư viện","tài liệu mượn thư viện","sách thư viện"
234
+ }),
235
+ "giu_xe_ve_sinh": _no_diacritics_set({
236
+ "bãi giữ xe","nhà giữ xe","gửi xe","thẻ xe","quẹt thẻ","phí gửi xe",
237
+ "nhà vệ sinh","toilet","giấy vệ sinh","nước rửa tay",
238
+ "ống nước nhà vệ sinh","cống thoát nhà vệ sinh","sàn nhà vệ sinh","wc nhà vệ sinh"
239
+ }),
240
+ "cong_quan_ly_dao_tao": _no_diacritics_set({
241
+ "trang quản lý đào tạo","cổng đào tạo","hệ thống đào tạo","portal","cổng thông tin",
242
+ "đăng nhập cổng đào tạo","quên mật khẩu","reset mật khẩu","quá tải","treo","tra cứu điểm",
243
+ "web đào tạo","cổng sinh viên","hệ thống online","trang web đào tạo"
244
+ }),
245
+ },
246
+
247
+ "khac": {
248
+ "hoc_phi": _no_diacritics_set({
249
+ "học phí","thu thêm","biên lai","miễn giảm","chính sách học phí","công khai học phí",
250
+ "đóng tiền","nộp học phí","thu tiền","hoá đơn học phí","chính sách","phiếu thu","biên nhận",
251
+ "đóng học","nộp lệ phí","phí học","thanh toán học phí","biên lai học phí","phiếu thu học phí"
252
+ }),
253
+ "hoc_bong": _no_diacritics_set({
254
+ "học bổng","học bổng kkht","tiêu chí học bổng","điểm chuẩn học bổng",
255
+ "nộp hồ sơ học bổng","kết quả học bổng","trễ hạn học bổng","xét học bổng",
256
+ "điều kiện học bổng","quỹ học bổng","thông báo học bổng","hồ sơ học bổng","điểm xét"
257
+ }),
258
+ "hanh_chinh": _no_diacritics_set({
259
+ "thủ tục hành chính","hành chính","giấy tờ hành chính","đóng dấu","xác nhận sinh viên","giấy xác nhận",
260
+ "phòng ctsv","tiếp nhận hồ sơ hành chính","trả kết quả hành chính","xin giấy tờ hành chính","nộp hồ sơ hành chính","biểu mẫu hành chính",
261
+ "phòng đào tạo hành chính","chứng nhận hành chính","xác minh hành chính","giấy phép hành chính","bản sao hành chính","văn thư hành chính"
262
+ }),
263
+ "clb": _no_diacritics_set({
264
+ "câu lạc bộ","clb","tuyển thành viên","hoạt động clb","ngoại khóa","sự kiện","workshop",
265
+ "đăng ký clb","đoàn hội","event","team","cuộc thi","hoạt động sv",
266
+ "hoạt động ngoại khoá","nhóm sinh viên","sự kiện trường","đăng ký tham gia"
267
+ }),
268
+ "ktx": _no_diacritics_set({
269
+ "ký túc xá","kí túc xá","ktx","ở ghép","phòng ktx","bảo vệ ktx","giờ giới nghiêm",
270
+ "điện ktx","nước ktx","khu ở ktx","an ninh ktx","phòng chung ktx",
271
+ "toà ktx","khu vực ở ktx","quản lý ktx"
272
+ }),
273
+ "mot_cua": _no_diacritics_set({
274
+ "văn phòng một cửa","vp1c","phòng một cửa","nộp hồ sơ một cửa","số thứ tự","lấy giấy một cửa","trả giấy một cửa","trả kết quả một cửa",
275
+ "hồ sơ một cửa","giấy tờ một cửa","số lượt một cửa","quầy tiếp nhận một cửa","một cửa"
276
+ }),
277
+ "dang_ky_tin": _no_diacritics_set({
278
+ "đăng ký môn","đăng ký tín chỉ","đk tín","đk môn","server đăng ký",
279
+ "xếp lịch tự động","lọc trùng lịch","hệ thống đăng ký",
280
+ "đăng ký online","chọn môn","mở lớp","đóng lớp","sắp lịch","hệ thống đăng ký tín chỉ"
281
+ }),
282
+ "diem_ren_luyen": _no_diacritics_set({
283
+ "điểm rèn luyện","drl","đánh giá rèn luyện","minh chứng drl","chấm drl",
284
+ "minh chứng","điểm rl","bảng drl","đánh giá cá nhân","đánh giá tập thể"
285
+ }),
286
+ },
287
+ }
288
+
289
+ _VI_LETTER = re.compile(r"[A-Za-zÀ-ỹĐđ]")
290
+
291
+ def _is_garbage(txt: str) -> bool:
292
+ """
293
+ Kiểm tra text có phải garbage (quá ngắn hoặc không phải tiếng Việt) để bỏ qua.
294
+ """
295
+ t = str(txt).strip()
296
+ if len(t) < 4:
297
+ return True
298
+ if len(t.split()) < 2:
299
+ return True
300
+ letters = sum(1 for ch in t if _VI_LETTER.match(ch))
301
+ return (letters / max(1, len(t))) < 0.4
302
+
303
+ def _aspect_has_kw(aspect_vi: str, s_norm: str) -> bool:
304
+ """Kiểm tra aspect có keyword trong sentence không (chỉ keywords >= 3 ký tự)"""
305
+ for kws in SUBTOPIC_KW.get(aspect_vi, {}).values():
306
+ for kw in kws:
307
+ kw_norm = _norm_match(kw)
308
+ # Chỉ match với keywords >= 3 ký tự để tránh false positive
309
+ if len(kw_norm) >= 3 and kw_norm in s_norm:
310
+ return True
311
+ return False
312
+
313
+ def _pick_subprompt(aspect: str, sentence: str) -> str:
314
+ s = _norm_match(str(sentence))
315
+ for sub, kws in SUBTOPIC_KW.get(aspect, {}).items():
316
+ # Chỉ match với keywords >= 3 ký tự
317
+ for kw in kws:
318
+ kw_norm = _norm_match(kw)
319
+ if len(kw_norm) >= 3 and kw_norm in s:
320
+ return ASPECT_PROMPTS[aspect].get(sub, ASPECT_PROMPTS[aspect]["_default"])
321
+ return ASPECT_PROMPTS[aspect]["_default"]
322
+
323
+
324
+
325
+ def _has_any_kw(s_norm: str) -> bool:
326
+ """Kiểm tra sentence có keyword của bất kỳ aspect nào không"""
327
+ for aspect_vi in ASPECTS_VI:
328
+ if _aspect_has_kw(aspect_vi, s_norm):
329
+ return True
330
+ return False
331
+
332
+
333
+ def get_prompt(aspect_en: str, sentence: str = "", use_subprompt: bool = False) -> str:
334
+ """
335
+ Lấy prompt cho aspect (dùng subprompt nếu cần).
336
+ """
337
+ aspect_vi = ASPECT_REVERSE_MAPPING.get(aspect_en, "khac")
338
+ if use_subprompt and sentence:
339
+ return _pick_subprompt(aspect_vi, sentence)
340
+ aspect_prompts = ASPECT_PROMPTS.get(aspect_vi, {})
341
+ return aspect_prompts.get("_default", "")
342
+
models.py CHANGED
@@ -14,7 +14,6 @@ class User(UserMixin, db.Model):
14
  is_admin = db.Column(db.Boolean, default=False, nullable=False)
15
  created_at = db.Column(db.DateTime, default=datetime.utcnow)
16
 
17
- # Relationship với feedbacks
18
  feedbacks = db.relationship('Feedback', backref='user', lazy=True)
19
 
20
  def set_password(self, password):
@@ -33,11 +32,11 @@ class Feedback(db.Model):
33
 
34
  id = db.Column(db.Integer, primary_key=True)
35
  text = db.Column(db.Text, nullable=False)
36
- sentiment = db.Column(db.String(20), nullable=False) # positive, neutral, negative
37
- topic = db.Column(db.String(50), nullable=False) # lecturer, training_program, facility, others
38
  sentiment_confidence = db.Column(db.Float, nullable=False)
39
  topic_confidence = db.Column(db.Float, nullable=False)
40
- user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) # Bắt buộc vì đã login_required
41
  created_at = db.Column(db.DateTime, default=datetime.utcnow)
42
 
43
  def __repr__(self):
 
14
  is_admin = db.Column(db.Boolean, default=False, nullable=False)
15
  created_at = db.Column(db.DateTime, default=datetime.utcnow)
16
 
 
17
  feedbacks = db.relationship('Feedback', backref='user', lazy=True)
18
 
19
  def set_password(self, password):
 
32
 
33
  id = db.Column(db.Integer, primary_key=True)
34
  text = db.Column(db.Text, nullable=False)
35
+ sentiment = db.Column(db.String(20), nullable=False)
36
+ topic = db.Column(db.String(50), nullable=False)
37
  sentiment_confidence = db.Column(db.Float, nullable=False)
38
  topic_confidence = db.Column(db.Float, nullable=False)
39
+ user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
40
  created_at = db.Column(db.DateTime, default=datetime.utcnow)
41
 
42
  def __repr__(self):
requirements.txt CHANGED
@@ -14,14 +14,7 @@ transformers==4.44.0
14
  tokenizers==0.19.1
15
  huggingface-hub>=0.23.2
16
  safetensors
17
- datasets>=2.19.0
18
 
19
  # Data Processing and Utilities
20
- numpy
21
- requests
22
- tqdm
23
  pytz==2023.3
24
  schedule>=1.2.0
25
-
26
- # Performance Optimization
27
- hf_transfer>=0.1.4
 
14
  tokenizers==0.19.1
15
  huggingface-hub>=0.23.2
16
  safetensors
 
17
 
18
  # Data Processing and Utilities
 
 
 
19
  pytz==2023.3
20
  schedule>=1.2.0
 
 
 
static/css/style.css CHANGED
@@ -904,3 +904,29 @@ a.text-secondary.fw-bold:hover {
904
  margin-left: 0 !important; /* icon sát trái */
905
  }
906
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
904
  margin-left: 0 !important; /* icon sát trái */
905
  }
906
 
907
+ /* Multiple Topics Display Styles */
908
+ .multiple-topics-container {
909
+ display: flex;
910
+ flex-direction: column;
911
+ gap: 1rem;
912
+ }
913
+
914
+ .topic-sentiment-item {
915
+ background: var(--gray-100);
916
+ border-radius: 12px;
917
+ padding: 1rem;
918
+ border-left: 4px solid var(--gray-400);
919
+ transition: all 0.3s ease;
920
+ }
921
+
922
+ .topic-sentiment-item:hover {
923
+ background: var(--gray-200);
924
+ transform: translateX(4px);
925
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
926
+ }
927
+
928
+ .topic-sentiment-item .fw-semibold {
929
+ color: var(--text-primary);
930
+ font-size: 1.05rem;
931
+ }
932
+
static/js/app.js CHANGED
@@ -146,20 +146,21 @@ document.addEventListener('DOMContentLoaded', function() {
146
  body: JSON.stringify({ text: feedbackText })
147
  });
148
  const data = await response.json();
 
149
  if (response.ok) {
150
- utils.updateResult('sentiment', data.sentiment);
151
- utils.updateResult('topic', data.topic);
152
- elements.originalText.textContent = feedbackText;
153
- elements.results.style.display = 'block';
154
- elements.results.classList.add('fade-in');
155
- utils.scrollToResults();
156
 
157
  // Clear the textarea after successful analysis
158
  elements.textarea.value = '';
159
  utils.updateCharCounter();
160
 
161
- // Reload feedback history after successful analysis
162
- loadFeedbackHistory(1);
 
 
 
 
163
  } else {
164
  utils.showError(data.error || 'Có lỗi xảy ra khi phân tích feedback!');
165
  }
@@ -256,8 +257,10 @@ async function loadFeedbackHistory(page = 1, shouldScroll = false) {
256
  ...filterParams
257
  });
258
 
 
259
  const response = await fetch(`/api/feedback-history?${queryParams.toString()}`);
260
  const data = await response.json();
 
261
  if (response.ok) {
262
  displayFeedbackHistory(data.feedbacks);
263
  displayPagination(data, page);
@@ -461,6 +464,97 @@ function getSentimentColor(sentiment) {
461
  return colors[sentiment] || 'secondary';
462
  }
463
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
464
  // Time Filter Functions
465
  function initTimeFilter() {
466
  const timeFilterInputs = document.querySelectorAll('input[name="timeFilter"]');
@@ -578,15 +672,18 @@ function initAnalysisModeToggle() {
578
  const csvModeInput = document.getElementById('csvMode');
579
  const singleForm = document.getElementById('singleFeedbackForm');
580
  const csvForm = document.getElementById('csvUploadForm');
 
581
 
582
  // Show single form by default
583
  singleForm.style.display = 'block';
584
  csvForm.style.display = 'none';
 
585
 
586
  singleModeInput.addEventListener('change', function() {
587
  if (this.checked) {
588
  singleForm.style.display = 'block';
589
  csvForm.style.display = 'none';
 
590
  }
591
  });
592
 
 
146
  body: JSON.stringify({ text: feedbackText })
147
  });
148
  const data = await response.json();
149
+ console.log('Predict response:', data);
150
  if (response.ok) {
151
+ // Display multiple topics with sentiments
152
+ displayMultipleResults(data.results, feedbackText);
 
 
 
 
153
 
154
  // Clear the textarea after successful analysis
155
  elements.textarea.value = '';
156
  utils.updateCharCounter();
157
 
158
+ // Reload feedback history after successful analysis with delay
159
+ // to ensure database has committed the new data
160
+ console.log('Reloading feedback history...');
161
+ setTimeout(() => {
162
+ loadFeedbackHistory(1, false);
163
+ }, 500);
164
  } else {
165
  utils.showError(data.error || 'Có lỗi xảy ra khi phân tích feedback!');
166
  }
 
257
  ...filterParams
258
  });
259
 
260
+ console.log('Loading feedback history, page:', page);
261
  const response = await fetch(`/api/feedback-history?${queryParams.toString()}`);
262
  const data = await response.json();
263
+ console.log('Feedback history response:', data);
264
  if (response.ok) {
265
  displayFeedbackHistory(data.feedbacks);
266
  displayPagination(data, page);
 
464
  return colors[sentiment] || 'secondary';
465
  }
466
 
467
+ // Display multiple topics with sentiments
468
+ function displayMultipleResults(results, text) {
469
+ const elements = {
470
+ results: document.getElementById('results'),
471
+ originalText: document.getElementById('originalText')
472
+ };
473
+
474
+ // Validate elements exist
475
+ if (!elements.results) {
476
+ console.error('❌ Results element not found');
477
+ return;
478
+ }
479
+
480
+ if (!results || results.length === 0) {
481
+ // No topics detected or all below threshold
482
+ if (elements.originalText) elements.originalText.textContent = text;
483
+ elements.results.innerHTML = `
484
+ <div class="alert alert-warning">
485
+ <i class="fas fa-info-circle me-2"></i>
486
+ Không phát hiện topic rõ ràng trong feedback này.
487
+ </div>
488
+ `;
489
+ elements.results.style.display = 'block';
490
+ elements.results.classList.add('fade-in');
491
+ Utils.scrollToSection(elements.results, 105);
492
+ return;
493
+ }
494
+
495
+ // Display original text first (before innerHTML clears it)
496
+ if (elements.originalText) {
497
+ elements.originalText.textContent = text;
498
+ }
499
+
500
+ // Build HTML for multiple topics
501
+ let html = '<div class="mb-4"><h5 class="mb-3"><i class="fas fa-check-circle me-2"></i>Kết quả phân tích:</h5></div>';
502
+ html += '<div class="multiple-topics-container">';
503
+
504
+ results.forEach(result => {
505
+ const sentimentConfig = getSentimentConfig(result.sentiment);
506
+ const topicConfig = getTopicConfig(result.topic);
507
+ const sentimentColor = getSentimentColor(result.sentiment);
508
+
509
+ html += `
510
+ <div class="topic-sentiment-item mb-3">
511
+ <div class="d-flex align-items-center">
512
+ <div class="flex-grow-1">
513
+ <div class="d-flex align-items-center mb-2">
514
+ <i class="fas ${topicConfig.icon} me-2" style="color: #6B7280;"></i>
515
+ <span class="fw-semibold">${topicConfig.label}</span>
516
+ </div>
517
+ <div>
518
+ <span class="badge bg-${sentimentColor} me-2">
519
+ <i class="fas ${sentimentConfig.icon} me-1"></i>
520
+ ${sentimentConfig.label}
521
+ </span>
522
+ <small class="text-muted">
523
+ <i class="fas fa-percentage me-1"></i>
524
+ Độ tin cậy: ${(result.confidence * 100).toFixed(1)}%
525
+ </small>
526
+ </div>
527
+ </div>
528
+ </div>
529
+ </div>
530
+ `;
531
+ });
532
+
533
+ html += '</div>';
534
+
535
+ // Add original text to the end
536
+ html += `
537
+ <div class="card shadow-sm">
538
+ <div class="card-header bg-primary text-white">
539
+ <h5 class="card-title mb-0">
540
+ <i class="fas fa-quote-left me-2"></i>
541
+ Feedback Gốc
542
+ </h5>
543
+ </div>
544
+ <div class="card-body">
545
+ <blockquote class="blockquote mb-0">
546
+ <p class="mb-0">${text}</p>
547
+ </blockquote>
548
+ </div>
549
+ </div>
550
+ `;
551
+
552
+ elements.results.innerHTML = html;
553
+ elements.results.style.display = 'block';
554
+ elements.results.classList.add('fade-in');
555
+ Utils.scrollToSection(elements.results, 105);
556
+ }
557
+
558
  // Time Filter Functions
559
  function initTimeFilter() {
560
  const timeFilterInputs = document.querySelectorAll('input[name="timeFilter"]');
 
672
  const csvModeInput = document.getElementById('csvMode');
673
  const singleForm = document.getElementById('singleFeedbackForm');
674
  const csvForm = document.getElementById('csvUploadForm');
675
+ const results = document.getElementById('results');
676
 
677
  // Show single form by default
678
  singleForm.style.display = 'block';
679
  csvForm.style.display = 'none';
680
+ if (results) results.style.display = 'none'; // Hide results when switching to single mode
681
 
682
  singleModeInput.addEventListener('change', function() {
683
  if (this.checked) {
684
  singleForm.style.display = 'block';
685
  csvForm.style.display = 'none';
686
+ if (results) results.style.display = 'none'; // Hide CSV results
687
  }
688
  });
689