Update
Browse files- PhoBERTPairABSA.py +24 -0
- app.py +188 -218
- database_manager.py +5 -49
- forms.py +1 -8
- model_config.py +342 -0
- models.py +3 -4
- requirements.txt +0 -7
- static/css/style.css +26 -0
- 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
|
| 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
|
| 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
|
| 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)
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
| 119 |
model.load_state_dict(state_dict, strict=False)
|
| 120 |
model.to(device)
|
| 121 |
model.eval()
|
| 122 |
-
except Exception
|
| 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"
|
| 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,
|
| 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 |
-
|
| 379 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
"
|
| 426 |
-
"
|
| 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
|
| 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
|
| 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
|
| 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
|
| 543 |
-
|
| 544 |
except csv.Error as e:
|
| 545 |
-
return jsonify({'error': f'File CSV không đúng định dạng: {str(e)}
|
| 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):
|
| 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 |
-
|
| 568 |
-
if tokenizer is None:
|
| 569 |
results.append({
|
| 570 |
"row": row_num,
|
| 571 |
"feedback": feedback_text,
|
| 572 |
-
"
|
| 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 |
-
|
| 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 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
|
|
|
|
|
|
|
|
|
| 627 |
|
| 628 |
results.append({
|
| 629 |
'row': row_num,
|
| 630 |
'text': feedback_text[:100] + '...' if len(feedback_text) > 100 else feedback_text,
|
| 631 |
-
'sentiment':
|
| 632 |
-
'topic':
|
| 633 |
-
'sentiment_confidence': round(
|
| 634 |
-
'topic_confidence': round(
|
| 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(
|
| 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 |
-
|
| 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],
|
| 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
|
| 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 |
-
|
| 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
|
| 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
|
| 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)
|
| 37 |
-
topic = db.Column(db.String(50), nullable=False)
|
| 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)
|
| 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 |
-
|
| 151 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|